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:
- Global
animation-timeline: autoreset (this page, Step 1) β Best when all scroll-driven elements live in a single layer. Lowest specificity burden; easiest to maintain. @layer accessibilityoverride (this page, Step 2) β Best when your project uses cascade layers for component isolation. No!importantneeded; 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. useReducedMotionhook (this page, Step 4) β Best for React, Vue, or Svelte components that apply inlinestyleprops to drive animations. Media queries do not reach inline styles at all.- Blanket
* { animation: none }withoutanimation-timelineβ Avoid this. It misses scroll-linked timelines entirely and has unpredictable effects on third-party components.
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:
- Reads the OS preference at mount time.
- 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:
- Chrome DevTools β Rendering tab β Emulate CSS media feature
prefers-reduced-motionβ set toreduce. - Performance panel β record a scroll gesture or trigger a route change.
- In the timeline, filter for
AnimationandCompositeevents on elements that previously animated. There should be none. - Elements panel β select a scroll-animated element β Computed β confirm
animation-timelineshowsnoneorauto, not aScrollTimelineorViewTimeline. - 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. - 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.
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 clusterβ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.
Related
- Implementing prefers-reduced-motion in Scroll-Driven Animations β parent guide covering CSS custom property architecture, ARIA live regions, and focus management
- Motion Scaling & User Preferences β design-token approach for proportional motion reduction rather than binary on/off
- Browser Support & Progressive Enhancement β
@supportsguard patterns and the cascade discipline that makes accessibility overrides composable - Implementing View Transitions for React Router β where to place the JS motion guard in a React routing context
- How the View Transition API Works Under the Hood β compositor snapshot lifecycle and why CSS alone cannot abort the snapshot phase