Reduced motion
What each motion token becomes when a reader asks for less movement. A stated reduced variant per token, not a kill switch that leaves things teleporting.
Overview
The common implementation of prefers-reduced-motion is one rule at the bottom
of a stylesheet that sets every duration to something negligible. It is
well-intentioned, it satisfies an automated check, and it produces an interface
in which things teleport: a sheet that was somewhere and is now somewhere else,
with nothing in between to say which.
That is a downgrade, not an accommodation. Somebody who set the preference did not ask to be told less about where things went; they asked not to be made to feel unwell. Those are different requests and the second one has a better answer.
So in opsinjs reduced motion is a property of a token, declared alongside it rather than bolted on underneath every token at once. A token that declares a reduced variant states what it becomes, and the answer is rarely "nothing". Six of the thirteen motion tokens declare one today; the token section below names which, and says what the other seven mean for a component built on them.
This is often confused with an accessibility feature you add at the end. It is not: a token whose reduced variant was not considered when it was authored usually cannot have one retrofitted, because the animation was designed to carry information the still version does not.
How it works
The reduction is authored per token, in three parts: what the duration becomes, what the easing becomes, and what the property being animated becomes. Almost everyone omits that last one.
That third part is where the real work is. Reduced motion is a request to reduce movement, not to reduce change. An opacity fade involves no movement at all and is generally safe. A transform that moves an element several hundred pixels is not. So the reduction usually replaces a large translate with a small one, or with a fade, rather than removing the transition:
| Full | Reduced | Why |
|---|---|---|
| Sheet slides up the full height | Sheet fades in with a very short rise | Direction is preserved; the distance that provokes symptoms is not |
| Detail view morphs from a tile | Detail view crossfades in place | Continuity is carried by position, not by a travelling container |
| Overlay scales the layer beneath back | Layer beneath dims only | Depth is signalled by luminance instead of scale |
| List staggers in reading order | List appears together | Reading order is carried by the DOM, where it always was |
| Count-up over the first reveal | Final value, immediately | The animation was an emphasis; the value is the content |
| Skeleton shimmer loops | Static skeleton, no loop | A looping animation is the classic reduced-motion offender |
| Spring with overshoot | Two-stop linear(0, 1), short duration | Oscillation is more provocative than a single move |
Durations shrink but do not vanish. A transition that completes in a frame or two is functionally a teleport. The reduced durations are short enough to feel immediate and long enough that the eye registers a change rather than a discontinuity.
Nothing is left half-implemented. A component whose reduced variant has not
been specified does not ship. The variant is part of the component's Motion
section, demonstrated with <MotionDemo> so the fallback is observable rather
than described, and it is checked at review.
--opsin-ease-spring-sheet · --opsin-duration-spring-sheet. Under prefers-reduced-motion: reduce this becomes 1ms with a flat curve. The element still moves, and it simply arrives at once. The checkbox simulates the preference; if you have actually set it, app/globals.css has already applied it and the two states will look the same.
Using it
Do not test it by turning motion off and checking nothing crashes. Test it by turning motion off and asking whether you can still tell where things came from. If you cannot, the reduced variant is wrong, not the reader's setting.
Do
Under reduced motion the log sheet fades in over a very short rise from the bottom edge. It is obvious it came from below and obvious how to send it back.
Don’t
Under reduced motion the log sheet is simply there. The reader cannot tell whether they opened a sheet, navigated to a screen, or had something thrown at them, and the dismiss gesture is now a guess.
Respect the preference everywhere, including in the docs. Every demo on this
site honours prefers-reduced-motion, and <MotionDemo> also carries an explicit
toggle so a reader who has not set the preference can see what somebody who has
would get. A design system that animates its own documentation while telling you
to respect the setting is not credible.
Do not use reduced motion as a proxy for anything else. It is not a signal about data saving, about device capability, or about a preference for haptics. See Sound and haptics. It says one thing and you may infer only that thing.
Offer an in-product setting as well. The media query reflects an operating-system preference somebody may have set for a different reason, and some people want less movement from a health app specifically. An in-product control that can only ever reduce motion further, never override the system preference upwards, is the correct shape.
Tokens
tokens/motion.json is where a reduced variant is declared, one token at a time.
Six of the thirteen motion tokens carry one. Four are the springs spring-snap,
spring-settle, spring-calm and spring-sheet, which each become a linear
easing over a shortened duration of their own. The fifth is ease-standard,
which becomes linear. The sixth is duration-shimmer, which drops to zero so a
skeleton stops sweeping. Those six are what scripts/build-tokens.mts emits
inside @media (prefers-reduced-motion: reduce) in app/tokens.generated.css: an
easing and a duration for each spring, the easing for ease-standard, and the
shortened duration-shimmer.
The other seven declare nothing, so the media query leaves them alone and they
keep their full values for a reader who has asked for less movement. They are
ease-enter, ease-exit, and the five plain durations duration-instant,
duration-fast, duration-base, duration-slow and duration-deliberate. This
is a gap in the token layer rather than a position: a transition written with
--opsin-duration-base and --opsin-ease-enter still runs for its full 220ms
under the preference. Until those declarations exist, a component built on a
plain duration has to reduce itself. Put the transition inside the query in the
component's own stylesheet, or reach for a spring token, which carries its
fallback with it.
The table below is the generated list of what each motion token is and what reads
it. The reduced variants are not a column in it: scripts/build-tokens.mts
records one as reducedMotionValue in lib/generated/tokens.ts, and no view has
been written that publishes it yet.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-ease-spring-snaplinear(0, 0.0715, 0.2271, 0.4053, 0.5722, 0.7119, 0.8198, 0.8978, 0.9505, 0.9836, 1.0025, 1.0118, 1.015, 1.0147, 1.0126, 1.01, 1.0073, 1.0051, 1.0033, 1.0019, 1) | Direct manipulation only: a switch the reader just flipped, a segmented control, a pressed button settling. It overshoots by 1.5%. That is enough to feel physical but not enough to look playful. The 1.5% is the largest overshoot in the system but not the only one: `spring-settle` overshoots by 0.88%. Only `spring-calm` and `spring-sheet` reach their target without passing it. | no component |
| --opsin-duration-spring-snap283ms | Settle time for the spring-snap spring, measured from its own parameters. | no component |
| --opsin-ease-spring-settlelinear(0, 0.0742, 0.2328, 0.4113, 0.5758, 0.7116, 0.8157, 0.8905, 0.9412, 0.9736, 0.9928, 1.003, 1.0076, 1.0088, 1.0082, 1.0068, 1.0053, 1.0038, 1.0026, 1.0017, 1) | The workhorse for chrome: popovers, tooltips, menus, chips appearing and disappearing. | no component |
| --opsin-duration-spring-settle382ms | Settle time for the spring-settle spring, measured from its own parameters. | no component |
| --opsin-ease-spring-calmlinear(0, 0.0829, 0.2457, 0.4157, 0.5642, 0.6832, 0.774, 0.841, 0.8893, 0.9236, 0.9476, 0.9643, 0.9757, 0.9836, 0.9889, 0.9926, 0.995, 0.9967, 0.9978, 0.9985, 1) | A health value that changes while it is already on screen: a bar re-filling from one reading to the next, a dial travelling between two values the reader has already been shown. Never a first paint and never a first reveal. A value arrives at its final figure, with no count-up, no dial sweep and no line drawing itself in (health/motion-in-health-ui rule 2). Slightly overdamped (zeta just over 1) so it never overshoots and never bounces. | no component |
| --opsin-duration-spring-calm550ms | Settle time for the spring-calm spring, measured from its own parameters. | no component |
| --opsin-ease-spring-sheetlinear(0, 0.0881, 0.2576, 0.431, 0.5798, 0.6971, 0.7854, 0.8497, 0.8958, 0.9282, 0.9508, 0.9664, 0.9771, 0.9845, 0.9895, 0.9929, 0.9952, 0.9967, 0.9978, 0.9985, 1) | Large surfaces travelling a long distance: sheets, dialogs, full-screen pushes. Overdamped, because a sheet that bounces at the top of its travel reads as a dropped object. | dialog, sheet |
| --opsin-duration-spring-sheet483ms | Settle time for the spring-sheet spring, measured from its own parameters. | dialog, sheet |
| --opsin-ease-standardcubic-bezier(0.2, 0, 0, 1) | Non-spring transitions where a spring would be overkill: colour, opacity, border. Fast out, slow in. | accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-ease-entercubic-bezier(0.05, 0.7, 0.1, 1) | Something arriving from off-screen or from nothing. Decelerating, because an arrival should feel like it is coming to rest. | no component |
| --opsin-ease-exitcubic-bezier(0.3, 0, 0.8, 0.15) | Something leaving. Accelerating and shorter than its enter, because a reader does not need to watch a dismissal finish. | dialog |
| --opsin-duration-instant80ms | State change with no travel: hover tint, focus ring, checkbox tick. | no component |
| --opsin-duration-fast140ms | Small elements moving a small distance. | accordion, body-map, button, checkbox, combobox, consent-sheet, dialog, menu, number-field, popover, progress, radio-group, scale-input, scroll-area, segmented-control, select, sheet, skeleton, slider, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-duration-base220ms | The default for chrome that is not spring-driven. | dialog, sheet |
| --opsin-duration-slow360ms | Layout change: a list reflowing, a card expanding. | no component |
| --opsin-duration-deliberate560ms | The ceiling on a transition a reader is waiting on, such as a first-run reveal or a consent sheet, where the point is that the reader notices. No such transition may exceed it. A looping placeholder period, like the skeleton shimmer, is bounded instead by its iteration count, so a single sweep of it may run longer. | no component |
| --opsin-duration-shimmer1600ms | One sweep of a loading placeholder's sheen. This is a period, the time for the sheen to make one pass across the bar, and not a travel time. At 1600ms the movement reads as calm rather than as urgency, which the first motion rule requires of anything that is not a value the reader controls. Its consumer pairs it with a finite iteration count so the total motion stays under the five seconds at which WCAG 2.2 SC 2.2.2 engages. | skeleton |
Declare a reducedMotion fallback on the seven motion tokens that have none,
then make scripts/build-tokens.mts --check fail when a motion token omits
one, so that the policy tokens/motion.json states about itself is enforced
rather than asserted. What each duration becomes under the preference is a
design decision nobody has taken, and it is not a number this page may pick;
publishing the reduced variant beside the full one in <TokenTable> waits on
the same decision.
Accessibility impact
- This is a WCAG requirement and a health one. SC 2.3.3 Animation from Interactions covers motion triggered by interaction; honouring the preference is how the system satisfies it. Independently of conformance, vestibular symptoms such as nausea, dizziness and migraine are exactly the outcomes a health product must not cause.
- Reduced is not removed, and the distinction protects two different groups. Removing motion entirely disadvantages readers who rely on it to track where things went, including many readers with cognitive disabilities. The reduced variants keep the spatial information and drop the provocative distance.
- The setting is not rare. Treat it as a first-class rendering mode that a meaningful share of readers use every day, not an edge case checked once before a release.
- Reduced motion must not change what is announced. The accessible name, the state and the announcement order are identical in both modes. If a component announces something different with motion reduced, the announcement was tied to an animation event and needs rewriting.
- The docs site holds itself to this, and the result is audited on This site's accessibility.
The conformance position, the tested combinations and the manual test route are on Accessibility → Reduced motion.
Related
- Springs as tokens explains why an overshooting curve needs a different reduction from a damped one.
- Choreography says what a morph, a stagger and a depth change each collapse to.
- Accessibility → Reduced motion is the conformance side of the same contract, with the test steps.
Choreography
What moves together and in what order. Continuity over replacement, morph rather than swap, depth rather than translation, and one thing moving at a time.
Motion tokens
The generated reference for every duration, easing and spring in the system, with each curve plotted from the parameters it was derived from.