skip to content

You own a design system whose components animate. How do you make reduced-motion support a structural guarantee rather than per-component discipline, and how do you let a user override the operating-system preference from inside the app?

level: principalimportance: nice to knowfreq 22%

answer

  1. one gate, not fifty component blocks
  2. components consume, never declare durations
  3. the media query adds no specificity
  4. an attribute selector outranks bare :root
  5. system default, with an explicit user override

basics

~20 s

Route every duration through a small set of motion custom properties gated once at the root, so components never write a raw duration and cannot forget the query. Layer an app-level override on top by keying the same properties off a root attribute, which outranks the media query on specificity.

solid answer

~50 s

Make it impossible to get wrong rather than asking everyone to remember. Expose a handful of motion tokens — say `--motion-duration-fast` and `--motion-duration-base` — defined once on `:root`, and have every component write `transition: transform var(--motion-duration-base) ease`. One `@media (prefers-reduced-motion: reduce)` block redefines those properties to a near-zero value, and the whole system responds. Because custom properties are inherited and resolved at computed-value time, changing them at the root propagates everywhere without touching a component. For an in-app override, key the same tokens off a root attribute such as `:root[data-motion="reduced"]`; that selector is more specific than the bare `:root` in the media query, so it wins in both directions regardless of source order — the app setting can force motion off, and cautiously back on. Keep a global reset underneath as a floor for third-party CSS, and enforce the tokens with lint rules rather than review.

code

css · 22 lines
css
:root {
  --motion-duration-base: 240ms;
  --motion-ease: cubic-bezier(0.2, 0, 0, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-duration-base: 0.01ms;
  }
}

:root[data-motion="reduced"] {
  --motion-duration-base: 0.01ms;
}

:root[data-motion="full"] {
  --motion-duration-base: 240ms;
}

.ds-dialog {
  transition: opacity var(--motion-duration-base) var(--motion-ease);
}

go deeper

for a junior

Understand the shape: components read a duration token instead of hardcoding milliseconds, and one place decides what that token is worth.

for a middle

Explain why redefining a custom property at the root changes every component — inheritance plus computed-value-time resolution — and why the media query alone cannot outrank an attribute selector.

for a senior

Describe the rollout: the token layer, the global floor for third-party CSS, the lint rule that makes raw durations a build failure, and reduced motion as a tested state rather than a review reminder.

for a principal

Own the contested calls — whether an app setting may re-enable motion against the system signal, what the shipped default is, and how you measure adoption so the policy is real rather than documented.

## The failure this is solving Per-component discipline does not survive a growing team. The reduced-motion query is easy to forget, invisible in review unless the reviewer turns the OS setting on, and impossible to see in a screenshot. Any policy whose enforcement mechanism is "remember to add the block" degrades to partial coverage within a few quarters. The design-system answer is to move the decision to a single place that every component passes through anyway. ## One gate, consumed everywhere Define motion durations as custom properties, and forbid raw durations in component CSS. ```css :root { --motion-duration-fast: 120ms; --motion-duration-base: 240ms; --motion-ease: cubic-bezier(0.2, 0, 0, 1); } @media (prefers-reduced-motion: reduce) { :root { --motion-duration-fast: 0.01ms; --motion-duration-base: 0.01ms; } } .ds-dialog { transition: opacity var(--motion-duration-base) var(--motion-ease); } ``` This works because custom properties inherit and resolve at computed-value time: redefining them on the root changes what every descendant's `transition` computes to, with no per-component code. A component author who writes the token gets correct behaviour without knowing the preference exists, and a component author who writes `240ms` directly is caught by a lint rule rather than by an accessibility audit six months later. The near-zero rather than zero value carries the same reasoning as the classic reset: transitions and animations still complete, so `transitionend` and `animationend` still fire and any control flow hanging off them keeps working. ## Tokens are not enough on their own Durations are the easy half. Two further layers are needed. **Distance and property choice.** A slide that takes 0.01ms is not a slide, so duration tokens do neutralise movement — but they neutralise it by making it instant, which discards the feedback. Where a component's motion carried meaning, it should degrade to a crossfade rather than to nothing, and that decision is per component. A workable rule: the token layer guarantees safety, and components that care add an explicit `reduce` block to substitute rather than flatten. Safety is structural; quality is opt-in. **Motion outside CSS durations.** Autoplaying video, animated images, canvas loops, timer-driven carousels, and anything animated from script are untouched by a duration token. Those need the preference consulted at their own layer, and the design system should provide the primitive rather than leaving each product to reinvent it. ## The in-app override Users ask for a product-level control for good reasons: they want motion off on this site only, or they are on a shared or managed machine where they cannot change the OS setting, or the OS one is on for another reason entirely. The system preference is the default; the app setting is an explicit override of that default. ```css :root { --motion-duration-base: 240ms; } @media (prefers-reduced-motion: reduce) { :root { --motion-duration-base: 0.01ms; } } /* app setting wins over the system default, in both directions */ :root[data-motion="reduced"] { --motion-duration-base: 0.01ms; } :root[data-motion="full"] { --motion-duration-base: 240ms; } ``` The precedence here is not luck. A media query contributes nothing to specificity, so the rule inside it has the specificity of `:root` alone; `:root[data-motion="reduced"]` adds an attribute selector and therefore outranks it no matter where the blocks sit in the file. That is what makes the override robust against someone later reordering the stylesheet. The attribute itself is set by application code from the user's stored choice, which is outside CSS's remit. The genuinely contested design decision is whether to offer "full" at all. Letting an app setting turn motion *back on* for someone whose OS says reduce is arguably overriding an accessibility signal — the defensible position is that an explicit, per-site, opt-in choice by the user themselves is a preference, not a bypass, provided it is never the default and never remembered across a change in the system setting without them noticing. Some teams offer only "system default" and "reduced" for that reason. Be ready to argue your side. ## Making it stick - **Lint the raw value.** A rule that rejects time units in component CSS outside the token file turns policy into a build failure. - **Keep a global floor.** The universal near-zero reset still earns its place for third-party stylesheets and anything predating the tokens. - **Default the toggle to system.** Three states — system, reduced, full — with system as the shipped default, so doing nothing inherits the OS answer. - **Test it as a state.** Treat reduced motion like a theme: a snapshot dimension in visual tests, a row in the component checklist, part of the review template. - **Watch adoption, not intentions.** The measurable thing is the count of raw durations outside the token layer; if it is not trending to zero the policy is not real.

  • Why does the attribute-based override win over the media query without depending on source order?
    Because a media query contributes nothing to specificity. The rule inside it is just `:root`, while the override is `:root[data-motion="reduced"]` — one attribute selector heavier, so it wins wherever it sits in the file. Relying on order instead would break the first time someone reorganises the stylesheet or introduces cascade layers.
  • Is a duration token enough, or does the system still need per-component reduced-motion blocks?
    It is enough for safety, not for quality. Zeroing the duration removes the movement but also removes the feedback the animation carried. Components where that feedback matters should add an explicit reduce block that substitutes an opacity crossfade for a transform. The tokens guarantee nobody ships something harmful; the per-component block is what makes the still version good.
  • Should the in-app toggle be allowed to turn motion back on for a user whose OS reports reduce?
    It is a real judgment call. The case for it: the user is making an explicit, informed, per-site choice, and the OS setting may be on for unrelated reasons or set by someone else on a shared machine. The case against: you are overriding an accessibility signal. If you offer it, never default to it, and make the control obviously reversible.
  • What does this approach not cover?
    Anything whose motion is not a CSS duration: autoplaying video, animated GIFs, canvas or WebGL loops, timer-driven carousels, and script-driven animation. Those consult the preference at their own layer. A design system should ship one primitive that exposes the resolved motion state so products query it in one place rather than each reimplementing the check.

saying these in an interview costs you the question

  • Relies on every component author remembering the media query
  • Puts the reduced-motion override before the base rules and trusts source order
  • Thinks zeroing durations also fixes video and script-driven motion
  • Treats an in-app toggle as a replacement for reading the system preference
  • Defaults the app setting to full motion rather than to the system value

context