opsinjs
AccessibilityContracts

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-slow and --opsin-duration-deliberate;
  • every spring easing collapses to linear(0, 1), and the properties set there are --opsin-ease-spring and --opsin-ease-spring-gentle.

Three consequences, and the second is the one that matters:

  1. 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.
  2. 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.
  3. 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

This table has not been generated. It is produced by 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-sheet and sheet read a duration through duration-(--opsin-duration-*), and skeleton gates its pulse on motion-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-motion is not exposed to React. There is no shipped hook, so JavaScript-driven animation has to read matchMedia directly.

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.

On this page