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.

WCAG 2.2 Animation Compliance Criteria

Map SC 2.2.2 Pause, Stop, Hide, SC 2.3.1 Three Flashes, and SC 2.3.3 Animation from Interactions onto scroll-driven animations and view transitions, with testable CSS/JS patterns and a repeatable audit workflow.

Vestibular-Safe Motion Design Patterns

Identify the motion characteristics that trigger vestibular symptoms — displacement scale, parallax depth, spin and zoom — and replace them with opacity and colour transitions using safe ranges and a design-review checklist.

Motion Preference Support Matrix: Browsers & Operating Systems

Shipped versions, flags, and OS-level toggles for prefers-reduced-motion and related media features across every major engine, with per-engine @media and @supports guard recipes.

Accessible Transitions: Focus & Announcements

What view transitions and scroll animations do not tell assistive technology — focus management, live regions, keyboard access, and reduced-motion parity across every code path.


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.

Browser rendering pipeline and accessibility tree gap The main thread runs JavaScript, style, layout, and paint. The compositor thread runs scroll-driven animation frames. The accessibility tree is updated only from the main thread, not from compositor frames. Main thread JavaScript Style Layout Paint Compositor thread Scroll animation Accessibility tree no signal to a11y tree

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)
Preference support versus timeline support A year axis from 2017 to 2026. A bar for prefers-reduced-motion spans the whole axis from Safari 10.1 in 2017. A shorter bar for animation-timeline scroll and view starts only in 2023 with Chrome 115. The six-year gap is marked above, showing reduced-motion overrides need no feature guard. six years of preference support before the first scroll timeline Motion preference prefers-reduced-motion — 2017 to 2020, every engine Scroll-driven timelines scroll() / view() 2017 2019 2021 2023 2025 A reduced-motion override needs no @supports guard: the query is older than every timeline API.

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 — the trigger characteristics and safe ranges are catalogued in Vestibular-Safe Motion Design Patterns. 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.

DevTools verification loop A cycle of four steps: emulate the reduce preference in the Rendering tab, read the computed animation-timeline value, open the Animations panel, then tighten the override and reload. The centre panel lists the two pass criteria: computed animation-timeline resolves to auto and the Animations panel lists no running entries. 1. Emulate prefers-reduced-motion = reduce 2. Computed tab animation-timeline on the animated node 3. Animations panel list every timeline still attached and running 4. Widen the reset raise the layer, add the missing selector, reload Pass criteria animation-timeline: auto Animations panel empty

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 h1 within 1–2 seconds of the transition completing.
  • Scroll past a scroll-reveal element: 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. For the full criterion-by-criterion mapping with testable patterns, see WCAG 2.2 Animation Compliance Criteria.


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.

Auditing an existing site for motion accessibility

Retrofitting motion accessibility onto a shipped site is a different exercise from building it in. The animation is already there, stakeholders are attached to it, and the work is to find every place motion reaches the user and decide what each one should do under a reduced-motion preference. A four-pass audit covers the surface without turning into an open-ended rewrite.

Pass one — inventory what animates. Load each template with the reduced-motion preference off and record every moving element: scroll-driven reveals, parallax layers, sticky-header state changes, route transitions, hover and focus effects, loading indicators, and anything a third-party embed brings with it. The most reliable mechanical aid is document.getAnimations(), which returns every running animation including scroll-driven ones and the pseudo-element animations a view transition generates. Run it at the top of the page, after a full scroll, and during a route change; each pass surfaces effects the previous one missed. Third-party embeds are the usual blind spot — a video player, a map, or a chat widget can introduce motion your stylesheet never mentions and your override never reaches.

Pass two — classify each effect. For every item in the inventory, record three facts: what starts it, whether it displaces anything spatially, and whether it repeats. Those three facts decide which criterion applies and which remedy is appropriate. Motion that starts on its own and repeats past five seconds needs an operable control regardless of preference. Motion the user starts and that displaces content needs to be disableable. Motion that only changes opacity or colour needs nothing beyond good taste. The classification is quick once the inventory exists, and it converts an intimidating list into three short ones.

Pass three — write the overrides. Group the effects by the mechanism that drives them, not by the page they appear on. Everything declared in a stylesheet is handled by a single reduced-motion block that resets animation-timeline, animation-range, animation and transition together. Everything created imperatively needs a JavaScript guard at the point of creation, which is usually a much smaller number of call sites than the inventory suggested. Everything a third party brings needs either a configuration option or a container-level override; where neither exists, the honest answer is to defer loading the embed until the user asks for it.

Pass four — verify with the preference on. Reload every template with the preference set at the operating system level rather than in DevTools, scroll each one to the bottom, exercise every route change, and re-run document.getAnimations(). An empty result is the pass condition. Anything still listed is either an effect the inventory missed or an override that did not reach it — and the entry itself names the element, which usually identifies the cause immediately.

The audit is worth scheduling as a recurring exercise rather than a one-off. Motion accumulates: a new marketing template, an upgraded component library, or a redesigned checkout each adds animation that the original override never anticipated. Sites that keep a reduced-motion assertion in continuous integration convert this from an audit into a regression test, which is the only form that survives a year of feature work.

Motion accessibility in design review

Most accessible-motion failures are decided long before the CSS is written, in a design review where nobody asked how the composition behaves for a user who cannot tolerate it. Three questions asked at that stage prevent most of the retrofit work described above.

What does this look like with the motion removed? If the answer is “the same, minus the flourish”, the effect is safe to disable and the reduced-motion path is trivial. If the answer is “the user would not know the page had changed”, the motion is carrying information, and the design needs a second, non-motion channel for that information before it ships — a heading change, a focus move, a status message.

How far does anything actually travel? Designers work in tools where a 200-pixel slide across a 2560-pixel canvas looks restrained. On a phone the same slide covers more than half the visual field. Ask for travel distances as a fraction of the smaller viewport dimension, and the conversation about safe ranges becomes concrete rather than aesthetic.

What happens when two of these play at once? Compositions are reviewed one component at a time and experienced all at once. A card that rises, a background that drifts, and a header that compresses are each defensible alone and jointly produce far more relative motion than any of them intended. Reviewing the composed page — scrolled at speed, not stepped through — is the only way to see it.

None of this requires an accessibility specialist in the room. It requires the three questions to be on the review checklist, which costs nothing and moves the expensive decisions to the point where changing them is cheap.


↑ Back to home