Accessibility & Inclusive Motion Standards
The CSS Scroll-Driven Animations specification (W3C Working Draft, shipping in Chromium 115+ and in Safari under active development) and the View Transitions API (Level 1 shipped in Chrome 111, Level 2 cross-document in Chrome 126) hand off visual state updates to the compositor thread’s isolated rendering pipeline, which interpolates frames without touching the DOM or the accessibility tree. That architectural choice delivers smooth 60 fps motion, but it also severs the natural synchronisation between visual change and assistive-technology notification — a gap that must be bridged deliberately. This guide establishes the technical standards, WCAG 2.2 compliance requirements, and implementation patterns required to build inclusive motion systems on that foundation.
In this section
Implementing prefers-reduced-motion
Map OS and browser motion preferences to compositor-safe CSS overrides. Covers media-query cascade order, animation-timeline reset requirements, JavaScript guards, and framework hooks for React, Vue, and Svelte.
Motion Scaling & User Preferences
Design-token architecture for proportional motion scaling. Define --motion-scale-factor and --motion-base-duration custom properties so components reduce displacement and pace rather than eliminating all animation.
Core concept: the compositor gap
Traditional requestAnimationFrame animations run on the main thread alongside DOM and ARIA mutations, so visual change and accessibility-tree change occur in the same task. CSS scroll-driven animations run on the compositor thread — the browser interpolates every frame without touching the DOM. This creates three distinct obligations:
1. prefers-reduced-motion must reach scroll timelines explicitly.
The animation shorthand resets animation-name, animation-duration, and related longhands — but it does not reset animation-timeline. A rule like animation: none leaves an active scroll or view timeline attached to the element, continuing to drive transform or opacity changes on the compositor. The correct override resets both:
/* Base scroll-driven animation */
.hero-parallax {
animation: parallax-scroll linear;
animation-timeline: scroll(root);
}
@keyframes parallax-scroll {
to { transform: translateY(-20%); }
}
/* Inclusive override — reset BOTH animation AND animation-timeline */
@media (prefers-reduced-motion: reduce) {
.hero-parallax {
animation: none;
animation-timeline: auto; /* shorthand does not reset this */
transform: none;
}
}
For the full cascade strategy — including @layer ordering, JavaScript guards, and per-framework hooks — see Implementing prefers-reduced-motion.
2. View Transitions capture DOM snapshots on the compositor.
document.startViewTransition() freezes a bitmap of the old state, swaps the DOM, then crossfades old-to-new entirely on the GPU. The swap and the transition run as separate phases; unless your code explicitly detects prefers-reduced-motion: reduce and skips or shortens the transition, the snapshot-and-crossfade executes regardless of the user’s preference. The View Transitions API does not honour the media query automatically.
3. The accessibility tree receives no signal from compositor transitions.
Screen readers are not notified when view-transition-name elements animate across the viewport, when a scroll-driven animation reveals content, or when document.startViewTransition() swaps pages. Focus management and aria-live updates must be wired manually to match each visual change.
Rendering pipeline implications
The style → layout → paint → composite sequence gives compositor-thread animations their performance advantage: the browser skips style, layout, and paint recalculation on every frame, costing roughly 0.1–0.3 ms per frame instead of 4–12 ms. The accessibility cost is the inverse of that benefit: nothing in that fast path updates ARIA attributes, fires mutation observers, or advances focus.
The 16 ms frame budget breaks down roughly as: 4 ms JavaScript, 3 ms style + layout, 2 ms paint, 7 ms compositor work and display. Scroll-driven animations consume only that final compositor slice — but assistive technology responds to the JavaScript and DOM phases, not the compositor phase. Any accessible announcement, focus move, or aria-live update must therefore be triggered from JavaScript, not from the animation itself.
The diagram above shows the critical gap: compositor-thread scroll frames produce no signal to the accessibility tree. Only code running on the main thread — your JavaScript event listeners and aria-live updates — can bridge that gap.
Browser support matrix
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
animation-timeline: scroll() |
115 (2023-07) | 115 (2023-07) | 110 (flag); 131 (stable, 2024-11) | Preview (in development) |
animation-timeline: view() |
115 (2023-07) | 115 (2023-07) | 110 (flag); 131 (stable, 2024-11) | Preview (in development) |
prefers-reduced-motion |
74 (2019) | 79 (2020) | 63 (2018) | 10.1 (2017) |
| View Transitions Level 1 (same-doc) | 111 (2023-03) | 111 (2023-03) | 130 (2024-10) | 18 (2024-09) |
| View Transitions Level 2 (cross-doc) | 126 (2024-06) | 126 (2024-06) | In development | In development |
@starting-style (entry animations) |
117 (2023-09) | 117 (2023-09) | 129 (2024-08) | 17.5 (2024-05) |
prefers-reduced-motion has the broadest support of any feature on this page — it predates scroll-driven animations by five years — so reduced-motion overrides can be written without any feature detection guard.
For a full breakdown of @supports guards and polyfill strategies, see Browser Support & Progressive Enhancement.
Progressive enhancement strategy
The baseline principle: content must be readable and navigable before any animation enhancement is applied. An element hidden by an animation that never fires is an accessibility failure, not a visual design choice.
/* 1. Baseline — always visible, no animation */
.scroll-reveal {
opacity: 1;
transform: none;
}
/* 2. Enhancement — requires scroll timeline support */
@supports (animation-timeline: scroll()) {
.scroll-reveal {
opacity: 0;
transform: translateY(2rem);
animation: reveal-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes reveal-in {
to { opacity: 1; transform: none; }
}
}
/* 3. Reduced-motion override — runs inside or outside @supports */
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
animation: none;
animation-timeline: auto; /* essential: shorthand alone is insufficient */
opacity: 1;
transform: none;
}
}
Layer 3 sits outside the @supports block intentionally: a browser that supports scroll timelines but has prefers-reduced-motion: reduce active must still hit the override. The cascade resolves correctly — the media query block wins by specificity when both conditions are true.
System preference integration
Granular motion scaling
Not every person with a motion sensitivity requires all animation removed. Vestibular disorders, for example, are most sensitive to translational displacement and parallax, less so to fades or colour transitions. CSS custom properties allow proportional scaling so components can reduce rather than eliminate motion:
:root {
--motion-base-duration: 400ms;
--motion-scale-factor: 1;
--motion-easing: cubic-bezier(0.25, 0.1, 0.25, 1);
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-base-duration: 0.01ms;
--motion-scale-factor: 0;
}
}
.scroll-reveal {
animation: fade-slide linear
calc(var(--motion-base-duration) * var(--motion-scale-factor));
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
@keyframes fade-slide {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: none; }
}
Setting --motion-scale-factor: 0 collapses the duration to 0ms, which prevents the animation running while keeping the custom-property architecture intact for intermediate values (e.g. --motion-scale-factor: 0.3 for “less motion” modes). Always accompany the duration collapse with an explicit animation-timeline: auto reset in the media query block. The full design-token system, including component-level overrides, is covered in Motion Scaling & User Preferences.
JavaScript-side preference detection
For View Transitions and @keyframes triggered from JS, read the media query directly:
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
async function navigateToRoute(newRoute) {
if (reducedMotion.matches) {
// Skip transition entirely — update DOM directly
await updateDOM(newRoute);
announceNavigation();
return;
}
const transition = document.startViewTransition(async () => {
await updateDOM(newRoute);
});
await transition.finished;
announceNavigation();
}
function announceNavigation() {
const h1 = document.querySelector('h1');
if (h1) {
h1.setAttribute('tabindex', '-1');
h1.focus({ preventScroll: true });
h1.removeAttribute('tabindex');
}
const announcer = document.getElementById('route-announcer');
if (announcer) {
announcer.textContent = `Navigated to ${document.title}`;
}
}
Listen to reducedMotion.addEventListener('change', handler) to respond to live system-preference changes without a page reload.
View Transitions and assistive technology
The View Transitions API’s SPA page swap pattern captures DOM snapshots and replays a crossfade entirely on the compositor. The accessibility implications are the same as for scroll-driven animations: the transition gives no signal to screen readers. Three things must happen on every navigation regardless of whether a transition runs:
Focus restoration. Move focus to the primary heading of the new view. This gives keyboard users an unambiguous entry point and causes screen readers to announce the new content.
Live-region announcement. Write the new page title into a persistent aria-live="polite" region. Without this, screen readers remain silent during route changes.
aria-busy during transition. Mark the transitioning container aria-busy="true" while document.startViewTransition() is running and clear it in transition.finished. This prevents screen readers from reading a partial DOM state.
<!-- Place once in your base layout -->
<div id="route-announcer"
aria-live="polite"
aria-atomic="true"
class="sr-only"></div>
async function accessibleViewTransition(updateFn) {
const container = document.getElementById('main-content');
const announcer = document.getElementById('route-announcer');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
container?.setAttribute('aria-busy', 'true');
if (reducedMotion.matches) {
await updateFn();
} else {
const t = document.startViewTransition(updateFn);
await t.finished;
}
container?.removeAttribute('aria-busy');
// Restore focus
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
heading.removeAttribute('tabindex');
}
// Announce
if (announcer) {
announcer.textContent = '';
// Flush, then write — ensures repeat navigations re-trigger the announcement
requestAnimationFrame(() => {
announcer.textContent = `Navigated to ${document.title}`;
});
}
}
Screen reader and animation synchronisation
CSS animations run independently of the accessibility tree. animationend and animationiteration DOM events bridge the two for progress indicators and status updates:
const indicator = document.querySelector('.progress-indicator');
const liveRegion = document.getElementById('status-announcer');
indicator?.addEventListener('animationiteration', () => {
const progress = calculateProgress();
// Announce at meaningful milestones only — avoid thrashing the screen reader
if (progress % 25 === 0) {
liveRegion.textContent = `Loading: ${progress}% complete`;
}
});
indicator?.addEventListener('animationend', () => {
liveRegion.textContent = 'Loading complete';
});
Use aria-live="polite" for background progress updates and aria-live="assertive" only for errors or critical state changes. Assertive regions interrupt screen reader speech immediately — overuse causes the reader to abandon mid-sentence and switch context, which is itself disorienting for users with cognitive disabilities.
Framework integration notes
React
React reconciles the virtual DOM on the main thread and commits changes in batches. Scroll-driven animations attached to refs survive reconciliation, but React’s useEffect cleanup must also reset animationTimeline explicitly, not just clear the animation style property:
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.animationTimeline = 'scroll(root)';
return () => {
el.style.animation = 'none';
el.style.animationTimeline = 'auto'; // reset explicitly
};
}, []);
For React Router view transitions, see Implementing View Transitions for React Router.
Vue
Vue 3’s <Transition> component uses JavaScript hooks (onEnter, onLeave) that run on the main thread. When a view-transition-name is also set, the two systems can conflict: Vue removes the element from the DOM during onLeave, which prematurely terminates any View Transition snapshot tied to that element. The fix is to set css: false on the <Transition> and drive animation entirely through startViewTransition.
Svelte
Svelte’s transition: directive compiles to inline style attributes at runtime, which override animation-timeline if both target the same element. Assign scroll timelines to a wrapper element and Svelte transitions to the inner element to avoid the conflict.
Debugging workflow
Emulate reduced-motion preferences
In Chrome DevTools: open the Rendering tab (via the three-dot menu → More tools → Rendering), find Emulate CSS media feature prefers-reduced-motion, and set it to reduce. Reload the page. Any scroll-driven animation that continues running after this toggle has a missing or incorrectly structured reduced-motion override.
Verify timeline reset
After toggling the preference, inspect a scroll-animated element in the Elements panel. Open the Computed tab and search for animation-timeline. If the value is not auto, the override is incomplete. The Animations panel (three-dot → More tools → Animations) will also show any running timelines — a correctly overridden element should show no active animations.
Layer promotion and GPU memory
In the Layers panel (three-dot → More tools → Layers), select an element that carries a view-transition-name. Each named element allocates a separate GPU compositing layer and snapshot buffer. Assign view-transition-name only to elements that genuinely need matched transitions — keeping the count under 10–15 per transition keeps GPU memory pressure manageable.
Screen reader testing
Run manual checks with NVDA + Chrome, JAWS + Edge, and VoiceOver on macOS. Specific checks for scroll-driven and view-transition pages:
- Navigate to a new route: the screen reader should announce the
h1within 1–2 seconds of the transition completing. - Scroll past a
scroll-revealelement: no new announcement should occur (opacity/transform changes are not structural changes and should not be announced). - Activate reduced-motion emulation: no animation panel entries should remain for previously animated elements.
WCAG 2.2 compliance reference
| Criterion | Level | Relevance |
|---|---|---|
| 2.3.3 Animation from Interactions | AAA | Motion triggered by user interaction (including scroll) must be disableable unless essential. |
| 1.4.13 Content on Hover or Focus | AA | Scroll-triggered tooltips must be dismissable without scroll interaction; they must not obscure other content. |
| 2.1.1 Keyboard | A | Scroll-triggered content changes must not trap keyboard focus or create unreachable content states. |
| 4.1.3 Status Messages | AA | Progress updates driven by animation must be announced via aria-live — the visual update alone is insufficient. |
| 2.4.3 Focus Order | A | After a view transition, focus must move to a logical entry point in the new view. |
| 1.1.1 Non-text Content | A | SVG animations used to convey meaning require <title> and <desc> elements plus role="img". |
SC 2.3.3 is Level AAA, which means it is not required for AA conformance — but it is the criterion most directly applicable to decorative scroll animations. Targeting it regardless of conformance level is the right baseline for vestibular disorder safety.
Key concepts index
animation-timeline
: The CSS property that assigns a timeline (scroll, view, or auto) to an element’s animation. Must be reset to auto in reduced-motion overrides — the animation shorthand does not reset it.
prefers-reduced-motion
: A CSS media feature that exposes the OS-level “reduce motion” preference. Values: no-preference (default) and reduce. Browser support dates to 2017–2018 across all major engines.
scroll() timeline
: An animation-timeline value that ties the animation’s progress to the scroll position of a scroll container. Syntax: scroll(<axis> <scroller>).
view() timeline
: An animation-timeline value that ties progress to an element’s intersection with its scroll container’s viewport. Accepts animation-range to scope the effect to entry, exit, or contain phases.
animation-range
: Defines the start and end points within a timeline where the animation runs. Keywords entry, exit, cover, and contain map to phases of an element’s intersection with the scroller.
document.startViewTransition()
: The JavaScript API that initiates a View Transition. Takes a callback that updates the DOM; the browser captures before/after snapshots and crossfades them on the compositor.
aria-live
: An ARIA attribute that marks a region as a live region — the browser announces textContent changes to screen readers without requiring focus. Values: polite (waits for speech to finish) and assertive (interrupts immediately).
will-change
: A CSS hint that promotes an element to its own compositor layer. Use sparingly: every promoted layer consumes GPU memory. Release it with will-change: auto once the animation completes.
@supports (animation-timeline: scroll())
: The feature-detection guard for scroll-driven animations. Wraps enhancement styles so they apply only in supporting browsers, leaving the baseline (visible, unanimated) intact for others.
view-transition-name
: A CSS property that identifies an element for matched transitions across View Transition states. Each named element allocates a GPU snapshot buffer; keep counts low to avoid memory pressure.
Related
- Implementing prefers-reduced-motion — full cascade strategy, JS guards, and framework hooks
- Motion Scaling & User Preferences — design-token architecture for proportional motion reduction
- The Rendering Pipeline for Scroll Animations — compositor thread, frame budget, and layer promotion
- Browser Support & Progressive Enhancement —
@supportsguard patterns and polyfill strategies - SPA Page Swap Animations — View Transitions API patterns with focus and announcement integration