opsinjs
FoundationsToken familiesMotion

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:

FullReducedWhy
Sheet slides up the full heightSheet fades in with a very short riseDirection is preserved; the distance that provokes symptoms is not
Detail view morphs from a tileDetail view crossfades in placeContinuity is carried by position, not by a travelling container
Overlay scales the layer beneath backLayer beneath dims onlyDepth is signalled by luminance instead of scale
List staggers in reading orderList appears togetherReading order is carried by the DOM, where it always was
Count-up over the first revealFinal value, immediatelyThe animation was an emphasis; the value is the content
Skeleton shimmer loopsStatic skeleton, no loopA looping animation is the classic reduced-motion offender
Spring with overshootTwo-stop linear(0, 1), short durationOscillation 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.

A sheet arriving, with and without the preference
148

--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.

TokenWhat it controlsUsed 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-snap283msSettle 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-settle382msSettle 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-calm550msSettle 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-sheet483msSettle 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-instant80msState change with no travel: hover tint, focus ring, checkbox tick.no component
--opsin-duration-fast140msSmall 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-base220msThe default for chrome that is not spring-driven.dialog, sheet
--opsin-duration-slow360msLayout change: a list reflowing, a card expanding.no component
--opsin-duration-deliberate560msThe 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-shimmer1600msOne 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
Not written yet.

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.

On this page