prefers-reduced-motion Architecture

Part of Accessible Motion Architecture.

The prefers-reduced-motion media feature exposes an operating-system preference at the CSS level, and the architecture that consumes it is a two-layer cascade: a broad global reset that suppresses motion by default, and a narrow restoration layer that re-enables only the animations a user genuinely needs. Structuring it this way turns motion accessibility from a per-component chore — where a single forgotten animation breaks the contract — into a system property that fails safe. This guide covers how the media query resolves, why two layers beat scattered per-component checks, and how to test both preference states.


What the two-layer cascade is

A reduced-motion cascade has exactly two responsibilities, and separating them is what makes it robust. The first layer is a low-specificity global rule, scoped inside @media (prefers-reduced-motion: reduce), that collapses animation-duration and transition-duration to a near-zero value on every element and pseudo-element. It is a blanket: it does not know or care which animations exist, so it cannot be defeated by a component the author forgot about. The second layer is a set of higher-specificity rules, inside the same media query, that restore the handful of animations which carry required information — a loading spinner, a focus signal, a toast that must still register — re-expressed as opacity changes or instant state transitions rather than spatial movement.

The reason to prefer this structure over disabling motion component by component is the failure mode. A per-component approach fails open: miss one component and its motion still runs for reduced-motion users. The global-reset approach fails closed: miss one component and it is suppressed anyway. You trade the risk of unwanted motion for the milder risk of over-suppression, which the restoration layer then corrects deliberately and visibly.


Execution model: where the preference resolves

prefers-reduced-motion is evaluated during the browser’s Style phase, as computed styles are built from the cascade. This timing is the whole point. Because the matched media-query rules are folded into the computed style before layout and paint run, the reduced-motion branch is authoritative for the very first frame the user sees. No animation paints and is then retracted; the calm path is simply what renders.

Contrast this with a JavaScript approach that reads matchMedia and toggles a class. That code runs after the document parses and, in a hydrated application, often after the first paint — leaving a window in which the full-motion CSS has already rendered a frame or two of movement before the class lands. For declarative motion, the cascade wins outright. JavaScript detection still has a role, but a different one: gating imperative motion such as the Web Animations API, or deciding whether to allocate an IntersectionObserver-driven layer, which is the subject of detecting prefers-reduced-motion in JavaScript.

Invalidation matters too. The preference can change while the page is open — a user toggles the OS setting, or an assistive tool flips it. When it changes, the browser re-resolves style and the active media branch swaps live, without a reload. CSS handles this for free; JavaScript listeners must subscribe to the MediaQueryList change event to keep pace.


Media feature and value reference

Feature / value Matches when Compositing tier Notes
prefers-reduced-motion: reduce User requested minimal motion in the OS Style-phase gate Host the global reset and restoration layer here
prefers-reduced-motion: no-preference User has expressed no preference Style-phase gate Wrap ambitious/decorative motion here so it is opt-in by default
animation-duration: 0.01ms !important Applied inside reduce Composite Collapses timelines without a hard none, so animationend still fires
animation-iteration-count: 1 !important Applied inside reduce Composite Stops infinite loops from replaying at near-zero duration
transition-duration: 0.01ms !important Applied inside reduce Composite Makes transitions instant while preserving state end values
scroll-behavior: auto !important Applied inside reduce Main Disables smooth-scroll travel, a common vestibular trigger
will-change: auto !important Applied inside reduce None Releases any GPU layer reserved for motion that will not play

The cascade, layer by layer

The diagram shows how the two layers stack inside a single @media (prefers-reduced-motion: reduce) block, and why later, more specific rules restore motion without the global reset being able to override them back.

The two-layer prefers-reduced-motion cascade An outer media-query container holds a wide Layer 1 global reset that collapses animation and transition duration on every element. An arrow marked cascade order points down to Layer 2, three higher-specificity component boxes that restore a toast, spinner, and focus ring as opacity-only motion. A caption notes everything else stays suppressed. @media (prefers-reduced-motion: reduce) Layer 1 — global reset (low specificity) *, ::before, ::after → animation & transition-duration: 0.01ms !important will-change: auto · scroll-behavior: auto cascade order → Layer 2 — restore functional motion as opacity (higher specificity) .toast--enter opacity 0 → 1 no translate .spinner opacity pulse no rotation .focus-ring opacity fade ring stays visible Everything else stays suppressed by Layer 1

Because both layers live inside the same media query, specificity and source order decide the outcome exactly as they would anywhere else in CSS. Layer 1 uses the universal selector, the lowest possible specificity; Layer 2 uses class selectors that outrank it and appear later in the source, so the restored animations win cleanly without !important gymnastics on the restoration side.


Annotated implementations

1. The global reset

Intent: collapse every animation and transition to near-zero for reduced-motion users, as a baseline that cannot be defeated by an unhandled component.

/* Layer 1 — the safety net. Ships before any component motion is written. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;   /* stop infinite loops replaying */
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;          /* kill smooth-scroll travel */
    will-change: auto !important;              /* release reserved GPU layers */
  }
}

Rendering Impact: main-thread Style pass only — durations collapse before paint. Using 0.01ms rather than none keeps animationend/transitionend firing so JavaScript lifecycles are not orphaned.

2. Restoring one functional animation as opacity

Intent: keep a loading spinner perceivable for reduced-motion users by swapping its rotation for an opacity pulse, so the “work in progress” signal survives.

/* Full-motion path for users who have expressed no preference */
@media (prefers-reduced-motion: no-preference) {
  .spinner {
    animation: spin 800ms linear infinite;
  }
  @keyframes spin {
    to { transform: rotate(360deg); }
  }
}

/* Layer 2 restoration — no rotation, but the element still signals activity */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: spinner-pulse 1.2s ease-in-out infinite;
  }
  @keyframes spinner-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.45; }
  }
}

Rendering Impact: transform: rotate() and opacity — composite only. The reduce branch runs an opacity-only pulse, which carries no vestibular risk.

3. Scoping ambitious decorative motion to no-preference

Intent: make a large entrance animation opt-in, so the default for reduced-motion users is simply the static end state, with no fallback rule required.

/* Decorative only — never runs unless the user has welcomed motion */
@media (prefers-reduced-motion: no-preference) {
  .hero__title {
    animation: hero-in 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
  }
  @keyframes hero-in {
    from { opacity: 0; transform: translateY(28px) scale(0.98); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
  }
}
/* No reduce branch needed: without no-preference matching, the element
   simply renders in its natural, un-animated end state. */

Rendering Impact: transform + opacity — composite only. Gating on no-preference means reduced-motion users never trigger the translate/scale travel at all.


DevTools workflow: verifying both states

Use this sequence to confirm the cascade behaves correctly under each preference before shipping.

Chrome / Edge — Rendering tab

  1. Open DevTools (F12), then the three-dot menu → More toolsRendering.
  2. Scroll to Emulate CSS media feature prefers-reduced-motion and choose prefers-reduced-motion: reduce.
  3. Reload and walk every interactive surface. Confirm decorative motion is gone and only the audited functional animations remain, each as opacity or an instant change.
  4. Switch the control to no-preference and confirm the full motion returns — this proves your no-preference blocks are actually gated, not always-on.
  5. In the Elements panel, select an animated node and open Computed → filter for animation-duration; under reduce it should read the collapsed value for anything not deliberately restored.

Firefox

  1. Open about:config and set ui.prefersReducedMotion to 1 (reduce) or 0 (no-preference).
  2. Reload and repeat the surface walk. Firefox applies the preference at the platform level, so it also affects smooth scrolling.

Automated coverage

  1. In Playwright, launch a context with reducedMotion: 'reduce' and assert that decorative animations resolve to their end state (for example, an element’s getBoundingClientRect is stable across the window the animation would have occupied).
  2. Run the same assertion with reducedMotion: 'no-preference' to lock in that motion is present when welcomed, catching regressions in either direction.

Failure modes and fixes

Problem: A newly added component animates for reduced-motion users even though the global reset is present. Root cause: The component sets an explicit animation-duration with !important, which outranks the universal reset that also uses !important but has lower specificity. Fix: Remove !important from the component’s own duration, or move the component’s motion inside a @media (prefers-reduced-motion: no-preference) block so it never competes with the reset in the first place.


Problem: Infinite-loop animations still flicker rapidly under reduce. Root cause: Collapsing only animation-duration leaves animation-iteration-count: infinite, so the timeline replays thousands of times per second at 0.01ms each. Fix: Add animation-iteration-count: 1 !important to the global reset, as shown, so each collapsed animation runs once and stops.


Problem: A JavaScript-driven animation ignores the cascade entirely and plays full motion. Root cause: The Web Animations API and imperative style writes are not governed by CSS media queries; the cascade cannot reach them. Fix: Gate the imperative path in script by reading the preference, as covered in detecting prefers-reduced-motion in JavaScript, and bail out before starting the animation.


Problem: Restored functional motion looks wrong because its transform values were never cleared. Root cause: The restoration rule changed the animation but left a stale transform translate on the element, so it appears offset. Fix: In the reduce branch, explicitly reset transform: none on the element and animate only opacity, keeping the restored motion spatially neutral. Concrete templates live in reduced-motion fallback patterns for keyframes.


Accessibility notes specific to this cascade

The cascade is the mechanism WCAG Success Criterion 2.3.3 (Animation from Interactions) anticipates for disabling non-essential interaction motion, and it directly supports the broader WCAG animation conformance picture. Two nuances are worth stressing. First, reduce does not mean no motion: a brief opacity fade under 200ms is widely considered acceptable and is exactly what the restoration layer should use, so do not over-correct into a completely static interface that loses useful feedback. Second, the restoration layer is where you protect essential signals such as focus indicators and loading states — coordinate it with focus and state motion accessibility so that removing spatial movement never removes the information the movement was carrying.

Finally, remember that the reset’s will-change: auto line is an accessibility and a performance decision. Suppressing an animation while leaving its layer promoted wastes GPU texture memory on motion that will never play, which is why this cascade is designed to hand back compositor resources as it silences movement.


Frequently asked questions

Why use two layers instead of disabling motion per component?

A single global reset guarantees that any animation a developer forgets to handle is still suppressed by default, which is the safe failure mode. Per-component suppression fails open: a missed component keeps its motion. The second layer then restores only the small, audited set of essential animations, so coverage is complete without hand-checking every component.

What is the difference between reduce and no-preference?

prefers-reduced-motion: reduce matches when the user has requested minimal motion in their operating system. no-preference matches when they have not. Authoring ambitious motion under no-preference and a calm fallback under reduce means the default for users who have expressed nothing is full motion, while those who opted out are served the calm path.

When in the rendering pipeline does the media query take effect?

prefers-reduced-motion is evaluated during style resolution, before layout and paint. Because the matched rules are applied as the computed style is built, the reduced-motion branch is in force for the very first frame, so no unwanted motion paints. This is why the cascade is more reliable than a JavaScript class toggle that runs after hydration.

Does the global reset break essential animations?

By itself it collapses every animation, including essential ones, to near-zero duration. That is why the second restoration layer exists: it re-enables the specific animations the user still needs, expressed as opacity or instant state changes. The reset is only safe when paired with a deliberate restoration layer.

How do I test the cascade without changing OS settings?

Chrome and Edge DevTools expose an Emulate CSS media feature control in the Rendering tab that toggles prefers-reduced-motion between reduce and no-preference without touching the operating system. Firefox offers ui.prefersReducedMotion in about:config. Automated coverage is possible with Playwright by launching a context with the reduced-motion option set.