Reduced motion
Reduced motion is a per-token degradation, not a kill switch. This page says what every animated token collapses to, and why the state change survives.
What we guarantee
prefers-reduced-motion: reduce is a stated preference, not a hint, and this
system treats it as one. The degradation is implemented in the token layer rather
than in each component, which is what makes it complete instead of
component-by-component.
In app/globals.css and app/product.css, under
@media (prefers-reduced-motion: reduce):
- every duration token collapses to
1ms, and the properties set there are--opsin-duration-instant,--opsin-duration-fast,--opsin-duration-base,--opsin-duration-slowand--opsin-duration-deliberate; - every spring easing collapses to
linear(0, 1), and the properties set there are--opsin-ease-springand--opsin-ease-spring-gentle.
Three consequences, and the second is the one that matters:
- A component that reads its duration from a token needs no reduced-motion code of its own. Coverage is a property of the token layer.
- The state change still happens. An element that was going to arrive still arrives; it arrives immediately. This is the difference between reducing motion and removing feedback, and removing feedback is its own accessibility failure. A reader who cannot see that the sheet opened has been given a worse experience in the name of a better one.
- Opacity cross-fades are kept. They do not move, so they do not provoke the vestibular response that the preference exists to prevent, and they preserve the sense that something changed. Translation, scale, parallax and rotation are what collapse.
What you own
Anything animated outside the token system. A CSS animation with a hard-coded
300ms, a spring from an animation library configured in JavaScript, a Lottie
file, an animated GIF, an autoplaying video: none of these are reached by the
media query above. Read the preference in JavaScript with
matchMedia('(prefers-reduced-motion: reduce)') and branch.
Auto-playing and looping content. SC 2.2.2 covers anything that moves for more than five seconds. A looping onboarding animation is the usual offender.
Scroll-linked effects. Parallax, scroll-driven scaling and sticky elements that grow or shrink as you scroll are among the most reliable triggers of motion sickness and are not covered by a duration token.
Meaningful transitions in your own flows. If a page transition is how the reader understands they moved between steps, replace it with something static rather than deleting it. That static replacement can be a step indicator, a heading change or an announcement.
How to check
Turn the preference on at the operating system
macOS: System Settings → Accessibility → Display → Reduce motion. iOS: the same path. Windows: Settings → Accessibility → Visual effects → Animation effects. Android: Settings → Accessibility → Remove animations. Emulating the query in developer tools works for a quick check but does not exercise platform-level behaviour.
Repeat the primary task
Expected result: every state change still occurs and is still perceivable; it simply happens at once. Failure looks like a dialog that appears to do nothing when opened, a value that changes with no indication it changed, or a loading state you cannot tell from a finished one.
Look for the thing that stopped
Anything that was pulsing, sliding, bouncing or spinning has stopped. For each, ask what it was telling the reader and confirm that something static is still telling them. A spinner that becomes invisible has taken the loading state with it.
Observe the degradation rather than trusting it
<MotionDemo> replays a real token on a real element with a reduced-motion
toggle, so the fallback is visible rather than described. See
Reduced motion in Foundations for the
token-by-token account.
Measured results
scripts/build-tokens.mts. Run pnpm run generate and reload.Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.
The intended generated artefact is a table of every motion token with its normal
value and its reduced value side by side, emitted from tokens/motion.json, so
that a token added without a reduced-motion value fails the build. The token
source exists; the table is not generated yet.
Known gaps
- Nothing fails the build when a token stops collapsing. The guarantee is
exercised rather than theoretical.
button,care-card,dialog,consent-sheetandsheetread a duration throughduration-(--opsin-duration-*), andskeletongates its pulse onmotion-safe:. The collapse itself is nevertheless asserted by no check. The table under Measured results is the artefact that would catch a regression; until it is generated, the only evidence is a manual read of the three stylesheets, and the ordering that makes the collapse work is easy to undo by moving an@import. - There is no lint rule preventing a hard-coded duration in component source. A stylelint plugin is proposed on Tooling and is not built.
prefers-reduced-motionis not exposed to React. There is no shipped hook, so JavaScript-driven animation has to readmatchMediadirectly.
Updates to this page
Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.
Screen readers
The combinations we intend to test, the announcement contract every status change must satisfy, and an honest table of untested cells.
Reduced transparency
What each rung of the material ladder becomes when the reader has asked for less translucency. The layout must not move, and this page says why.