Implementing prefers-reduced-motion in Scroll-Driven Animations
prefers-reduced-motion needs more than a blanket animation: none when your site uses scroll-driven timelines or the View Transitions API. Both APIs involve properties and behaviors that the animation shorthand does not control β most critically animation-timeline, which drives scroll-linked and view-linked timelines independently. This guide gives you the exact overrides, a CSS custom-property architecture, and a practical implementation checklist, all within the broader Accessibility & Inclusive Motion Standards framework.
Pages in this section
Syntax reference
The three properties you must override for every scroll-driven or view-transition animation:
| Property | Default | Reduced-motion override | Why |
|---|---|---|---|
animation |
β | none |
Stops @keyframes playback and clears the shorthand |
animation-timeline |
auto |
auto |
animation shorthand does not reset this; without it, scroll timelines keep running |
animation-duration (on ::view-transition-*) |
browser default | 0.01ms |
Collapses pseudo-element crossfades while preserving animationend event dispatch |
The reason 0.01ms is used rather than 0ms: browsers including Chrome skip animationend and animationiteration events for zero-duration animations in some code paths. Using 0.01ms makes the animation imperceptible while keeping the event pipeline intact for JavaScript cleanup callbacks.
Minimal working example
A self-contained pattern that covers scroll-driven animations and the basic media-query structure with no dependencies:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* Motion design tokens */
:root {
--motion-duration: 400ms;
--motion-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Scroll-driven reveal */
.card {
opacity: 0;
transform: translateY(2rem);
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal {
to { opacity: 1; transform: none; }
}
/* Reduced-motion override β two properties, not one */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0.01ms;
}
*, *::before, *::after {
animation-duration: var(--motion-duration) !important;
animation-iteration-count: 1 !important;
transition-duration: var(--motion-duration) !important;
animation-timeline: auto !important; /* critical: removes scroll binding */
}
}
</style>
</head>
<body>
<div style="height: 200vh; padding-top: 60vh;">
<div class="card">Revealed on scroll</div>
</div>
</body>
</html>
Under prefers-reduced-motion: reduce, the animation-timeline: auto !important rule resets every scroll-driven element back to a normal time-based timeline, and the near-zero duration then makes that time-based animation imperceptible.
Timeline scoping for reduced-motion overrides
animation-timeline has two forms relevant here: named scroll timelines declared with @scroll-timeline (deprecated) or scroll-timeline-name, and anonymous timelines using scroll() or view(). Both must be reset explicitly:
/* Named scroll timeline */
.progress-bar {
scroll-timeline-name: --page-progress;
animation: grow linear;
animation-timeline: --page-progress;
}
/* Anonymous view timeline */
.section-reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
/* Override both forms with a single rule */
@media (prefers-reduced-motion: reduce) {
.progress-bar,
.section-reveal {
animation: none;
animation-timeline: auto; /* resets named AND anonymous timelines */
opacity: 1;
transform: none;
}
}
The animation-timeline: auto override works because auto is the initial value β it detaches any scroll-linked or view-linked source and returns the animation to the default document timeline. If there is no animation to run at all (because you also set animation: none), the animation-timeline value is moot, but setting it defensively prevents future regressions if animation is re-introduced without a matching override.
For patterns using animation-range entry and exit keywords, the range values are similarly irrelevant once animation-timeline: auto removes the scroll source, so no separate animation-range override is needed.
Compositor-safe properties
Knowing which properties remain on the compositor thread helps you understand what still runs β and what stops β when motion overrides are applied:
| Property | Compositor thread | Main thread recalc | Notes |
|---|---|---|---|
transform |
Yes | No | Safe to animate; reset to none in reduced-motion |
opacity |
Yes | No | Safe to animate; reset to 1 in reduced-motion |
filter (blur, drop-shadow) |
Yes (Chrome/Edge) | Partial (Firefox) | Safe in Chrome; test in Firefox |
clip-path |
No | Yes | Forces paint; avoid in scroll-driven animations |
width, height, margin |
No | Yes | Forces layout; always main-thread |
background-color |
No | Yes | Triggers style recalc each frame |
will-change: transform, opacity |
β | β | Promotes layer; remove when animation ends |
When animation-timeline: auto is set under prefers-reduced-motion, the compositor no longer receives scroll-position updates for those animations. The GPU layer allocated by will-change should also be released:
@media (prefers-reduced-motion: reduce) {
.scroll-driven {
will-change: auto; /* release promoted layer β not needed if no animation runs */
}
}
Common implementation patterns
Pattern 1: CSS custom property architecture
Centralizing motion settings in custom properties lets one @media block control the entire design system:
:root {
--motion-duration: 400ms;
--motion-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0.01ms;
/* easing is irrelevant at near-zero duration; keep the variable intact */
}
/* Disable scroll-driven timelines globally */
*, *::before, *::after {
animation-duration: var(--motion-duration) !important;
animation-iteration-count: 1 !important;
transition-duration: var(--motion-duration) !important;
animation-timeline: auto !important;
}
/* Suppress View Transition pseudo-elements */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
}
}
Pattern 2: Per-component override with cascade layers
When your project uses @layer, the accessibility override must sit in a layer with higher precedence than your component and utility layers, removing the need for !important:
@layer base, components, utilities, accessibility;
@layer accessibility {
@media (prefers-reduced-motion: reduce) {
.hero__parallax-layer,
.section-reveal,
.sticky-progress {
animation: none;
animation-timeline: auto;
opacity: 1;
transform: none;
}
}
}
Pattern 3: View Transition fallback with ARIA announcement
When motion is suppressed, route changes happen as instant DOM swaps. Screen readers need an explicit announcement because there is no visual transition to prompt attention:
async function handleRouteTransition(targetUrl) {
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReduced && document.startViewTransition) {
const transition = document.startViewTransition(() => {
updateDOM(targetUrl);
});
await transition.finished;
} else {
updateDOM(targetUrl);
// Announce the navigation to assistive technology
const liveRegion = document.getElementById('transition-announcer');
if (liveRegion) {
liveRegion.textContent = `Navigated to ${document.title}. Content updated.`;
}
}
}
Keep the aria-live="polite" region in your base HTML so it is always present:
<div id="transition-announcer" aria-live="polite" aria-atomic="true"
class="sr-only"></div>
Pattern 4: Focus management after instant transitions
When transitions are suppressed, programmatic focus must happen without a visual animation to guide the eye. The focus call is deferred one frame to guarantee the DOM mutation has committed:
function restoreFocusAfterTransition(targetElement) {
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReduced) {
requestAnimationFrame(() => {
targetElement.focus({ preventScroll: true });
});
}
}
preventScroll: true avoids a sudden viewport jump that would itself be a vestibular trigger.
Browser support and @supports guard
prefers-reduced-motion has near-universal support (Chrome 74, Firefox 63, Safari 10.1, Edge 79). The animation-timeline property it needs to override is newer:
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
prefers-reduced-motion |
74 (2019) | 63 (2018) | 10.1 (2017) | 79 (2020) |
animation-timeline: scroll() |
115 (2023) | 110 (2023) | 17.2 (2024) | 115 (2023) |
animation-timeline: view() |
115 (2023) | 114 (2023) | 17.2 (2024) | 115 (2023) |
::view-transition-* pseudos |
111 (2023) | 131 (2024) | 18 (2024) | 111 (2023) |
Use @supports to isolate scroll-driven animations from browsers that do not implement animation-timeline, so that in those browsers the baseline content is always visible and the prefers-reduced-motion override never needs to reset a property that was never set:
/* Baseline: always visible β never hidden by animation setup */
.scroll-reveal {
opacity: 1;
transform: none;
}
/* Enhancement: apply the animation only if the browser supports it */
@supports (animation-timeline: scroll()) {
.scroll-reveal {
opacity: 0;
transform: translateY(2rem);
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
animation: none;
animation-timeline: auto;
opacity: 1;
transform: none;
}
}
}
Nesting the @media inside @supports ensures the override only applies in browsers where the animation was actually enabled β no orphaned animation-timeline: auto overrides on browsers that never used scroll timelines. For more on @supports progressive enhancement patterns, see the browser support and progressive enhancement guide.
Gotchas and failure modes
-
Resetting
animationwithout resettinganimation-timeline:animation: noneclears the keyframe name, duration, fill-mode, and other shorthand sub-properties, but it does not resetanimation-timeline. A scroll-driven animation withanimation: noneand no explicitanimation-timeline: automay still receive scroll-position updates from the compositor, silently keeping the connection alive. Always pairanimation: nonewithanimation-timeline: auto. -
!importantin the wrong layer: In a layered cascade (@layer),!importantreverses layer precedence. An!importantrule inbasebeats an!importantrule inaccessibility. Prefer placing reduced-motion overrides in the highest-precedence layer without!important, or use a global* { }block in a@layerthat wins. -
View Transitions still snapshotting when only CSS duration is reset: Setting
animation-duration: 0.01mson::view-transition-*pseudos stops the crossfade, butdocument.startViewTransition()still allocates GPU snapshot buffers for every element with aview-transition-name. On large DOM trees, this has measurable memory cost. SkipstartViewTransition()in JavaScript entirely whenprefers-reduced-motion: reduceis active. -
ARIA live regions not announcing on instant transitions: When the view transition is skipped and the DOM updates instantly, screen readers may not notice the content change because no focus shift or live region update was triggered. This is the silent failure most commonly missed in automated testing. Always explicitly update a
aria-liveregion after an instant DOM swap. -
prefers-reduced-motionnot updating at runtime:window.matchMedia(...).matchesis a snapshot β if the user changes their OS motion preference while your SPA is open, the cached value stays stale. UseaddEventListener('change', handler)on theMediaQueryListobject to react to runtime changes, or re-query the value inside each transition handler. -
requestAnimationFramefocus timing: Callingelement.focus()synchronously after an instant DOM swap can focus an element before React, Vue, or Svelte has committed its virtual-DOM diff. Wrapping inrequestAnimationFrame(orqueueMicrotaskfor micro-task level precision) guarantees the DOM is stable before focus is moved.
Performance checklist
- Animate only
transform,opacity, andfilterβ these are compositor-thread properties that do not trigger layout or paint recalc during scroll playback. - Set
will-change: transform, opacitybefore animation begins; reset towill-change: autowhen the animation is finished or whenprefers-reduced-motionis active. - Use a single global
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-timeline: auto !important; } }rule as a safety net, then override per-component only for cases that need a non-zero fallback state. - Skip
document.startViewTransition()in JavaScript rather than relying solely on CSS duration overrides β this prevents GPU snapshot allocation on large pages. - Keep concurrent
view-transition-nameassignments to the minimum set of elements that need morphing; each one allocates a GPU layer buffer even when the transition duration is zero. - Run
document.getAnimations().filter(a => a.playState !== 'idle')in the DevTools console after emulatingprefers-reduced-motion: reduceβ the result should be an empty array. - Profile with the Performance panel; the Compositor track should show no animation events for scroll-driven elements while the preference is active.
- Consider motion scaling rather than binary elimination for users who need reduced but not zero motion β continuous scaling keeps spatial context while respecting the OS preference.
Debugging and DevTools verification
- DevTools β Rendering tab β Emulate CSS media feature
prefers-reduced-motionβ set toreduce. - Performance panel β record a scroll and a route change; verify the Compositor track shows no animation events for scroll-linked elements.
- Elements β Computed β confirm
animation-timelinereadsautoon any element that was previously scroll-driven. - Console:
document.getAnimations().filter(a => a.playState !== 'idle')β expect an empty array. - Simulate a route change and verify the
aria-liveregionβs text content updates. - Lighthouse β Accessibility audit β confirm no motion-related violations.
For automated testing, Playwrightβs page.emulateMedia({ reducedMotion: 'reduce' }) and Cypressβs cy.emulateMedia({ reducedMotion: 'reduce' }) both emulate the preference before page load, letting you assert that animation-timeline computes to auto and that ARIA live regions receive the expected text after navigation.
Related
- How to Respect prefers-reduced-motion in CSS Animations β in-depth guide covering React, Vue, and Svelte hydration patterns
- Motion Scaling & User Preferences β continuous CSS custom-property scaling for users who need reduced, not zero, motion
- Understanding the CSS Scroll Timeline API β
scroll()andview()function syntax, named timelines, andanimation-rangesemantics - Browser Support & Progressive Enhancement β
@supportsguard patterns for scroll-driven and view-transition features