How to Respect prefers-reduced-motion in CSS: Scroll-Driven & View Transition Patterns

prefers-reduced-motion needs explicit, layered attention when your site uses scroll-driven animation timelines or the View Transitions API, because both features run partly on the compositor thread β€” where a blanket animation: none placed in the wrong cascade position may never reach. This page gives you the exact overrides for each API, framework hydration patterns, and a DevTools verification workflow, all within the Accessibility & Inclusive Motion Standards framework.

When to use this approach

Use the patterns on this page when you need precise, targeted control rather than a single global reset. Choose between the approaches as follows:

Override strategies by reach Four bars of increasing reach. A global animation-timeline reset covers stylesheet timelines. An accessibility cascade layer adds component overrides without !important. A JavaScript guard adds view transitions and Web Animations. A framework hook adds inline styles. A blanket universal animation none rule is marked as the anti-pattern because it misses scroll-linked timelines. how much of the animation surface each strategy actually reaches 1. animation-timeline: auto stylesheet timelines in one layer 2. @layer accessibility + component rules, no !important 3. JS guard on startViewTransition + snapshots, WAAPI 4. useReducedMotion() in components + inline Not on this ladder * { animation: none } β€” silently misses every scroll-linked timeline
  • Global animation-timeline: auto reset (this page, Step 1) β€” Best when all scroll-driven elements live in a single layer. Lowest specificity burden; easiest to maintain.
  • @layer accessibility override (this page, Step 2) β€” Best when your project uses cascade layers for component isolation. No !important needed; layer ordering does the work.
  • JS guard for startViewTransition() (this page, Step 3) β€” Required when you use the View Transitions API. CSS alone cannot stop the snapshot-and-crossfade machinery that the browser starts before any pseudo-element is created.
  • useReducedMotion hook (this page, Step 4) β€” Best for React, Vue, or Svelte components that apply inline style props to drive animations. Media queries do not reach inline styles at all.
  • Blanket * { animation: none } without animation-timeline β€” Avoid this. It misses scroll-linked timelines entirely and has unpredictable effects on third-party components.

Decision tree: choosing a prefers-reduced-motion override strategy A flowchart that guides developers through four decision points: whether they use cascade layers, whether they use View Transitions, whether they use a JS framework with inline styles, and whether they use scroll-driven animations. Each path leads to the recommended override technique. Using CSS cascade layers? (@layer base, components…) Yes Add @layer accessibility { animation-timeline: auto } No @media override with !important Using View Transitions API? (document.startViewTransition) Yes JS guard + 0.01ms CSS No JS framework + inline styles? (React, Vue, Svelte) Yes useReducedMotion hook No @media reduce β†’ animation: none + animation-timeline: auto

Implementation

Step 1: Reset animation-timeline, not just animation

The most common mistake is only resetting the animation shorthand. Because animation-timeline is a separate property that drives the entire scroll-linked pipeline, it must be explicitly reset to detach the scroll source:

@media (prefers-reduced-motion: reduce) {
  .scroll-driven {
    animation: none !important;
    animation-timeline: auto !important; /* detach from scroll() or view() */
  }
}

animation-timeline: auto returns the element to the default time-based progression, which at animation: none means no motion at all. Without this line, a scroll-linked animation can still advance its keyframes as the user scrolls, even after animation is nulled out.

Step 2: Use @layer to remove !important

If your project uses cascade layers, place the accessibility override in a layer that wins over utility or component layers. The @supports guard pattern used in progressive enhancement applies here in reverse β€” you are narrowing scope with @media instead of @supports, but the cascade discipline is the same:

@layer base, components, utilities, accessibility;

@layer accessibility {
  @media (prefers-reduced-motion: reduce) {
    .scroll-driven {
      animation: none;
      animation-timeline: auto;
    }
  }
}

Because accessibility is declared last in the layer order, it naturally wins over utilities and components without needing !important. This keeps the override composable β€” later layers or inline styles can still override it for genuinely essential motion.

Step 3: JS guard for the View Transitions API

The View Transitions API (document.startViewTransition()) is JavaScript-initiated. The CSS pseudo-element overrides suppress the crossfade animation, but the snapshot-and-composite machinery still runs unless you skip the transition call entirely in JS:

const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (!prefersReduced) {
  document.startViewTransition(() => updateDOM());
} else {
  updateDOM(); // instant DOM swap β€” no snapshot overhead
}

For SPA page-swap animations built with React Router, place this guard in the router’s navigation hook rather than at each call site.

If you still want a brief crossfade rather than a hard cut when motion is reduced, keep the call but force a near-zero duration via CSS:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
    animation-delay: 0ms !important;
  }
}

0.01ms rather than 0ms is intentional: some browsers skip the animationend event for zero-duration animations, which breaks JS cleanup callbacks that await transition.finished.

Step 4: Framework useReducedMotion hook

React, Vue, and Svelte components that apply inline style props to drive animations are not reachable by CSS media queries at all β€” inline styles have the highest specificity and bypass the cascade. The reliable fix is a useReducedMotion hook that does two things:

  1. Reads the OS preference at mount time.
  2. Subscribes to runtime changes (the user can update their OS setting without reloading the page).
// React β€” drop into any component or custom hook file
import { useState, useEffect } from 'react';

export function useReducedMotion() {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mql.matches);
    const handler = (e) => setReduced(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, []);

  return reduced;
}

Set the result as a data-reduced-motion attribute on the document root so CSS attribute selectors can piggyback on the same preference signal without triggering a full re-render on every frame:

const reduced = useReducedMotion();

useEffect(() => {
  document.documentElement.dataset.reducedMotion = reduced ? 'true' : 'false';
}, [reduced]);
[data-reduced-motion="true"] .scroll-driven {
  animation: none;
  animation-timeline: auto;
}

This pattern also works for Vue (watchEffect) and Svelte (onMount + $effect). The hook initialises to false on the server to avoid a hydration mismatch, so the data-reduced-motion attribute flips to true after the first client render if the preference is set β€” a one-frame repaint rather than a full visual flash.

Verification

Use these steps to confirm all motion is genuinely suppressed, not just visually hidden:

  1. Chrome DevTools β†’ Rendering tab β†’ Emulate CSS media feature prefers-reduced-motion β†’ set to reduce.
  2. Performance panel β†’ record a scroll gesture or trigger a route change.
  3. In the timeline, filter for Animation and Composite events on elements that previously animated. There should be none.
  4. Elements panel β†’ select a scroll-animated element β†’ Computed β†’ confirm animation-timeline shows none or auto, not a ScrollTimeline or ViewTimeline.
  5. Console: document.getAnimations().filter(a => a.playState !== 'idle' && a.playState !== 'finished') β€” this must return an empty array. A non-empty result means at least one animation is still running.
  6. Lighthouse β†’ Accessibility audit β†’ confirm no motion-related violations are flagged.

For automated test coverage, emulate the preference in Playwright:

// Playwright β€” set up in your test's beforeEach
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/your-route');

// Assert no active animations
const activeAnimations = await page.evaluate(() =>
  document.getAnimations().filter(a =>
    a.playState !== 'idle' && a.playState !== 'finished'
  ).length
);
expect(activeAnimations).toBe(0);

Edge cases and gotchas

animation-timeline survives animation: none The animation shorthand resets animation-name, animation-duration, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, animation-fill-mode, and animation-play-state β€” but not animation-timeline. It is a separately specified property that must be reset explicitly.

The full reset surface for one scroll-driven element A single animated element is shown with the four declarations that keep it moving: animation-timeline, animation-range, animation-name and transition. Resetting only animation-name leaves the timeline attached, which is why the element keeps tracking the scroller. .reveal one scroll-driven element animation-timeline animation-range animation-name transition 4 live declarations Partial reset animation-name only timeline still bound the element keeps tracking the scroller and re-runs on reload Complete reset animation-timeline: auto animation-range: normal animation: none transition: none element is static in its end state

0ms duration skips animationend events Setting animation-duration: 0ms causes some browsers (notably WebKit) to never fire animationend. Any JS code that resolves a Promise on animationend or calls a cleanup callback will hang silently. Use 0.01ms as the floor instead.

Cascade layers: order, not specificity, decides the winner A layer declared later wins regardless of selector specificity. If your accessibility overrides sit in an earlier layer than a utility class that sets animation-timeline, the utility wins even at lower specificity. Always declare @layer accessibility last.

prefers-reduced-motion does not update on page load in some SSR setups If you read window.matchMedia(...).matches in getServerSideProps or a Nuxt/SvelteKit server route, you will always get false because window does not exist server-side. Initialise to false and then update after mount, as the hook in Step 4 does.

startViewTransition is not available in Firefox (as of 2026-06) The JS guard if (!prefersReduced) that skips the transition call also naturally falls through to updateDOM() on browsers where document.startViewTransition is undefined. No separate typeof check is needed, but do verify the fallback path works correctly in your router β€” see browser-specific View Transition notes for the full compat picture.

Browser-specific notes

Chrome / Edge (Chromium) animation-timeline has been supported since Chrome 115 (July 2023). The DevTools Rendering tab emulation of prefers-reduced-motion is the most reliable way to verify overrides; Performance panel Compositor events disappear cleanly when animation-timeline: auto is set correctly.

Safari As of Safari 17.2 (December 2023), animation-timeline: scroll() and animation-timeline: view() are supported without flags. However, the View Transitions API is still behind a flag as of Safari 18. document.startViewTransition will be undefined, so the JS guard path is always taken β€” which means the ARIA live region announcement and focus management in the parent guide’s View Transition fallback patterns are especially important here.

Firefox Firefox does not yet ship animation-timeline for scroll-driven animations (flag only as of Firefox 127). The @supports (animation-timeline: scroll()) progressive-enhancement guard means scroll-driven animations simply do not run in Firefox at all, making the prefers-reduced-motion override there a no-op by default. Still include it for forward compatibility.

matchMedia change events All three engines fire MediaQueryList change events when the user updates their OS preference mid-session. The useReducedMotion hook in Step 4 subscribes to this; a pure-CSS approach naturally picks up the change on the next paint. Neither requires a page reload.


Up: Implementing prefers-reduced-motion