Expressing Reduced Motion in Design Tokens

Part of prefers-reduced-motion Architecture in Accessible Motion Architecture.

The problem

A design system with fifty components has fifty opportunities to forget the reduced-motion gate. Each component has its own media query, each one makes its own decision about what to remove, and reviewing whether they are consistent means reading fifty stylesheets.

The failures that result are not dramatic — one component keeps a 400 ms slide, another disables a focus indicator along with its animation, a third shortens a duration but keeps the travel. Individually each is small. Collectively they mean the reduced-motion experience is nobody’s responsibility.

Root cause: the decision is being made at the wrong layer

“How far should things move?” and “how long should motion take?” are system-level decisions. They are expressed in components only because that is where the CSS happens to live.

Moving them into tokens changes where the reduced-motion override has to be written. If a component’s slide distance is var(--distance-enter) rather than 12px, then collapsing every slide in the system is one declaration. If a hover’s duration is var(--duration-feedback), shortening all feedback is one declaration. The component does not need a media query at all, and — crucially — a component written next year inherits the gate without its author having to know the gate exists.

The naming matters as much as the mechanism. Tokens named for their value (--duration-300) cannot be overridden meaningfully, because “300 milliseconds” is not a thing the reduced-motion branch has an opinion about. Tokens named for their role (--duration-enter, --distance-reveal, --ease-decelerate) can be, because the branch knows that entering should be instant and travel should be zero.

Where the reduced-motion decision livesSame outcome for the components that remember; very different outcome for the ones that do not.Where the reduced-motion decision livesPer-component media queriesFifty places to get it rightInconsistent decisions acrosscomponentsNew components start ungatedReview means reading every fileCorrect by diligenceToken-layer overrideOne block defines the whole reducedexperienceEvery component inherits itNew components are gated by defaultReview means reading one fileCorrect by construction
Same outcome for the components that remember; very different outcome for the ones that do not.

Step-by-step resolution

Introducing a motion token layerThe migration can be incremental — each component converted is one fewer place to forget.Introducing a motion token layer1Define three duration tokens by role: feedback, enter and exit.Most systems need no more than this2Define two or three distance tokens for the travel a component may use.Distance is what carries vestibular risk3Define easing tokens by intent: decelerate, accelerate, standard.Curves become reviewable as a set4Convert components to reference tokens, deleting their local mediaqueries.Component CSS gets shorter5Write the single reduced-motion override on the root.One block gates the whole system
The migration can be incremental — each component converted is one fewer place to forget.

Production code pattern

/* Intent: every motion decision the system can make, in one place, with the
   reduced-motion branch expressed as a single override. */
:root {
  /* Durations by role. Feedback is what a control does under your finger;
     enter and exit are what a component does when it appears or leaves. */
  --duration-feedback: 140ms;
  --duration-enter:    260ms;
  --duration-exit:     200ms;

  /* Travel distances. This is the axis that carries vestibular risk, so it
     is the one that collapses completely. */
  --distance-nudge:  4px;
  --distance-reveal: 12px;
  --distance-panel:  28px;

  /* Curves by intent rather than by control points. */
  --ease-standard:   cubic-bezier(0.4, 0, 0.2, 1);
  --ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
  --ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
}

/* The entire reduced-motion policy for the whole design system. */
@media (prefers-reduced-motion: reduce) {
  :root {
    /* Keep durations non-zero so transitionend and animationend still fire
       and state machines that await them keep working. */
    --duration-feedback: 1ms;
    --duration-enter:    1ms;
    --duration-exit:     1ms;

    /* Travel is what causes discomfort: remove it entirely. */
    --distance-nudge:  0px;
    --distance-reveal: 0px;
    --distance-panel:  0px;

    --ease-standard:   linear;
    --ease-decelerate: linear;
    --ease-accelerate: linear;
  }
}

/* A component: no media query, no literals, and correct by construction. */
.card {
  translate: 0 var(--distance-reveal);
  opacity: 0;
  transition:
    translate var(--duration-enter) var(--ease-decelerate),
    opacity var(--duration-enter) linear;
}

.card.is-revealed { translate: 0 0; opacity: 1; }

.card:hover {
  transition-duration: var(--duration-feedback);
  translate: 0 calc(-1 * var(--distance-nudge));
}
// Intent: scripted effects read the same tokens, so both halves of the system
// respond to the preference through one source of truth.
const root = getComputedStyle(document.documentElement);
const duration = parseFloat(root.getPropertyValue('--duration-enter'));
const distance = parseFloat(root.getPropertyValue('--distance-panel'));

panel.animate(
  [{ translate: `0 ${distance}px`, opacity: 0 }, { translate: '0 0', opacity: 1 }],
  { duration, easing: root.getPropertyValue('--ease-decelerate').trim(), fill: 'both' }
);

Rendering Impact: translate + opacity — composite only. Token substitution happens during style resolution, so referencing a custom property costs nothing per frame compared with a literal.

Under the preference, that card still fades — from translate: 0 0 to translate: 0 0, over one millisecond, with a linear curve. Nothing moves, the reveal is instant, the events still fire, and not one line of the component changed.

What each token category should do under the preferenceCollapse distance, shorten duration, flatten easing — in that order of importance.What each token category should do under the preferenceDefaultUnder reduceWhyDistancetokens4-28px0pxTravel is what triggerssymptomsDurationtokens140-260ms1msInstant, but eventsstill fireEasingtokensShaped curveslinearNothing left to shapeOpacityfadesIncludedKeep, shortenedNo displacement, lowriskLoop countsAs designed1Repetition isdistracting
Collapse distance, shorten duration, flatten easing — in that order of importance.

Verification checklist

Constraints and trade-offs

  • A token layer only helps if it is used; one component with literals is one component outside the gate.
  • Non-zero durations under reduce are a deliberate compromise — a purist zero would break state machines that await animation events.
  • Tokens named by role need agreement about what the roles are, which is a design conversation before it is a code change.
  • Scripted effects must read tokens at the time they run, not at module load, or a mid-session preference change is missed.
  • Very distinctive components sometimes need a value outside the set; make that an explicit, reviewed exception rather than a silent literal.

Frequently asked questions

Why override tokens rather than the components?

Because a component-level override has to be repeated in every component, and the one that is forgotten is invisible until a user with the preference finds it. A token override is a single block that every component inherits by construction.

Should durations go to zero under reduced motion?

Distances should; durations usually should not. A near-instant duration such as one millisecond keeps transition and animation events firing, which state machines often depend on, while removing all perceptible motion. Collapsing distance is what removes the vestibular risk.

Do tokens work for scripted animations too?

Yes, if the script reads them. getComputedStyle(document.documentElement).getPropertyValue(‘–duration-enter’) gives a scripted effect the same value the cascade is using, including the reduced-motion override, which keeps both halves of the system in agreement.


Migrating a system that already has motion everywhere

A token layer is easy to introduce into a new system and awkward to retrofit into one with a hundred existing components. The migration that works is mechanical rather than heroic.

Start by inventorying the literals. A grep for ms) and for transition: across component CSS produces a list of every duration in the system, and the distribution is usually revealing: forty components, thirty-one distinct durations, and no reason for most of the differences. That histogram is the argument for the token set, and it also tells you what the token values should be — the three or four clusters the existing values already fall into.

Then convert by role rather than by file. Find every duration that is feedback on a control and replace all of them with --duration-feedback in one commit; do the same for entrances, then exits. Converting one component at a time produces a long period where the system is half-tokenised and neither the tokens nor the literals can be trusted, whereas converting one role at a time leaves the system consistent at every step.

Delete the component-level media queries last, and only after the token override is in place — otherwise there is a window where a component has neither its own gate nor the inherited one. Running the reduced-motion CI check throughout the migration turns that ordering risk into a failing test rather than a silent regression.