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.
Step-by-step resolution
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.
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
reduceare 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.
Related
- prefers-reduced-motion Architecture — the two-layer cascade this token set implements
- Choosing Safe Animation Durations & Thresholds — the ceilings the token values should respect
- Detecting prefers-reduced-motion in JavaScript — keeping scripted effects on the same source of truth