core accessibility principles for modern frameworks
Reduced Motion & Animation Accessibility
Motion is one of the most overused tools in modern framework UIs. Parallax sections, spring-physics page transitions, auto-playing carousels, and shimmering skeletons all ship by default in component libraries, yet for a meaningful slice of users they range from distracting to physically nauseating. Building on Core Accessibility Principles for Modern Frameworks, this guide shows how to detect the user's motion preference, honor it in both CSS and React, and design a reduced-motion variant of your interface rather than a broken, feedback-free fallback.
WCAG Success Criteria Mapped:
2.3.3 Animation from Interactions2.2.2 Pause, Stop, Hide2.3.1 Three Flashes or Below Threshold1.4.13 Content on Hover or Focus
Key Implementation Objectives:
- Understand which motion harms vestibular and cognitive users, and what the SCs require
- Adopt a motion-safe default so animation is opt-in, not opt-out
- Read
prefers-reduced-motionreliably in CSS and in React viamatchMedia - Design crossfade-style reduced variants that preserve feedback without movement
- Tame auto-playing carousels, parallax, video, spinners, and skeletons
Why Motion Harms Some Users
Large or unexpected motion can trigger a vestibular response—dizziness, nausea, headaches, or disorientation—in users with vestibular disorders, which affect a significant portion of adults. The trigger is not "animation" in the abstract; it is specifically spatial movement: content sliding across large distances, scaling dramatically, rotating, or moving in parallax where foreground and background travel at different speeds. A subtle 150ms opacity fade rarely causes harm, but a full-viewport slide transition routinely does.
Motion also affects users beyond the vestibular system. People with attention-related and cognitive disabilities can lose their place when content shifts unexpectedly, and looping or auto-playing motion competes for attention with the task at hand. This is why WCAG addresses motion from two angles.
2.3.3 Animation from Interactions (Level AAA) asks that motion triggered by user interaction—scrolling, hovering, clicking—can be disabled unless the motion is essential to the functionality. 2.2.2 Pause, Stop, Hide (Level A) requires that any moving, blinking, or auto-updating content that starts automatically and lasts more than five seconds can be paused, stopped, or hidden by the user. Together they push you toward a single principle: the user, not the designer, decides how much motion they experience.
The operating system already exposes that decision. macOS ("Reduce motion"), iOS, Windows ("Show animations"), Android, and GNOME all surface a system-level toggle, and browsers translate it into the prefers-reduced-motion media feature. Your job is to read it and respond.
Testing Hook: Enable "Reduce motion" in your OS, reload the app, and confirm that no large spatial transitions, parallax, or auto-playing loops remain. Anything still moving is a candidate for a reduced variant.
The prefers-reduced-motion Media Query
prefers-reduced-motion has two states: no-preference (the user has expressed no preference, so motion is acceptable) and reduce (the user wants minimal motion). The naming matters: there is no prefers-reduced-motion: more. You are detecting reduction, and the safest mental model is motion-safe: treat reduced motion as the baseline and layer richer animation on top only for users who have not asked to reduce it.
The naive approach scopes a reduce query to switch things off:
/* Reactive approach: define motion, then strip it under reduce */
.card {
transition: transform 200ms ease, box-shadow 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
This works but is fragile: every new animation you add must remember its own reduce override, and one forgotten rule ships motion to someone who explicitly disabled it. The motion-safe default inverts the responsibility—animation only exists inside a no-preference query, so forgetting the query means no animation rather than unwanted animation:
/* Motion-safe default: no animation unless the user is OK with it */
.card {
/* No transition here — the resting state is static */
}
@media (prefers-reduced-motion: no-preference) {
.card {
transition: transform 200ms ease, box-shadow 200ms ease;
}
.card:hover {
transform: translateY(-4px); /* spatial movement — gated behind no-preference */
}
}
For codebases too large to refactor every rule, a global escape hatch curtails the worst offenders while you migrate. Keep it conservative—near-zero duration rather than none, so JavaScript that waits on transitionend/animationend events still fires:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; /* stop infinite loops */
transition-duration: 0.01ms !important;
scroll-behavior: auto !important; /* disable smooth-scroll jumps */
}
}
This blanket rule is a floor, not a ceiling. It prevents catastrophic motion but it also kills useful feedback, which is why the next section is about designing a real reduced variant rather than scorching every animation to zero.
Testing Hook: In Chrome DevTools, open the Rendering panel and set "Emulate CSS media feature prefers-reduced-motion" to
reduce. Visual transitions should collapse instantly without the layout breaking.
Reading the Preference in React
CSS handles declarative styling, but framework animations—Framer Motion, react-spring, GSAP, or hand-rolled requestAnimationFrame loops—run in JavaScript and need the preference as a value. Read it through window.matchMedia and subscribe to changes so a user toggling the OS setting mid-session is respected without a reload.
'use client';
import { useEffect, useState } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
/**
* Returns true when the user has requested reduced motion.
* Defaults to `true` (the safe, motion-light state) so SSR and the
* first paint never flash large animation before hydration resolves.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(true);
useEffect(() => {
// window only exists on the client — read inside the effect.
const mql = window.matchMedia(QUERY);
setReduced(mql.matches);
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
mql.addEventListener('change', onChange); // live updates if the user toggles the OS setting
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
Defaulting to true is deliberate. On the server window does not exist, so the initial render must assume something; assuming reduced motion means the worst case at first paint is a missing flourish, never an unwanted lurch for a vestibular user. The depth of the hook—the SSR caveat, the change listener, and gating Framer Motion on the result—is covered in Respecting prefers-reduced-motion in React and CSS.
Framer Motion ships first-class support: its own useReducedMotion() hook reads the same media feature, and the MotionConfig component with reducedMotion="user" makes every nested motion element automatically drop transform and layout animations while preserving opacity. Prefer that built-in path when you already depend on the library, and reserve a custom hook for non-Framer animation.
Testing Hook: Toggle the OS "Reduce motion" setting while the app is open. With the change listener wired, gated animations should switch off immediately—no refresh required.
Designing a Reduced-Motion Variant
The most common mistake is treating reduced motion as a kill switch. When you delete a transition entirely, you often delete the feedback it carried: a toast that slid in now pops with no indication it is new, a modal that scaled up now appears with no sense of where it came from, an accordion that animated open now teleports. Reduced motion does not mean no change—it means no large spatial movement.
The accessible substitute is almost always a crossfade. Opacity transitions do not move content through space, so they sidestep vestibular triggers while still signaling that something changed. The decision flow below summarizes the rule of thumb.
In React this becomes a branch on the hook's value—same component, two presentation paths:
'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from './useReducedMotion';
export function Toast({ children }: { children: React.ReactNode }) {
const reduced = useReducedMotion();
// Full variant slides up; reduced variant only crossfades — feedback survives,
// spatial movement does not. (Honors 2.3.3 Animation from Interactions.)
const variants = reduced
? { hidden: { opacity: 0 }, visible: { opacity: 1 } }
: { hidden: { opacity: 0, y: 24 }, visible: { opacity: 1, y: 0 } };
return (
<motion.div
role="status" // announce the toast to assistive tech
initial="hidden"
animate="visible"
variants={variants}
transition={{ duration: reduced ? 0.12 : 0.25 }}
>
{children}
</motion.div>
);
}
The corollary: some motion is essential and may legitimately survive reduction. A progress bar that fills, a loading indicator that shows work is happening, or an animation that conveys the only available state information is exempt under 2.3.3. Keep those, but make them as small and non-spatial as the meaning allows.
Testing Hook: With reduced motion on, trigger every transient UI element (toasts, modals, accordions). Each must still visibly change state—if anything appears or disappears with zero perceptible feedback, the reduced variant went too far.
Auto-Playing Carousels, Parallax & Video
2.2.2 Pause, Stop, Hide targets content that moves on its own. Auto-advancing carousels are the canonical violation: they start automatically, loop indefinitely, and frequently lack any control to stop them. Three rules keep them compliant.
First, provide a visible, keyboard-operable pause/play control with an accurate accessible name (aria-label="Pause carousel" that flips to "Play carousel"). Second, do not auto-advance at all when prefers-reduced-motion: reduce is set—initialize the carousel paused and let the user step through with the controls. Third, pause on hover and on keyboard focus so a user reading a slide is never yanked to the next one.
'use client';
import { useEffect, useState } from 'react';
import { useReducedMotion } from './useReducedMotion';
export function useCarouselAutoplay(advance: () => void) {
const reduced = useReducedMotion();
// Start paused when the user reduced motion — autoplay never begins. (2.2.2)
const [playing, setPlaying] = useState(!reduced);
useEffect(() => {
if (!playing) return;
const id = setInterval(advance, 6000);
return () => clearInterval(id);
}, [playing, advance]);
return { playing, setPlaying }; // wire setPlaying to a labeled pause/play button
}
Parallax scrolling is 2.3.3 territory: the differential movement of layers as the user scrolls is interaction-triggered motion, and it is rarely essential. Gate parallax entirely behind @media (prefers-reduced-motion: no-preference) so reduced-motion users get a flat, static layout. The same applies to scroll-jacking and scroll-linked scale or rotation effects.
Auto-playing background video combines motion with 2.2.2 and bandwidth concerns. Either swap to a static poster image under reduced motion or, at minimum, render a prominent pause control and never loop silently below the five-second threshold without one.
Testing Hook: Tab through a carousel with a keyboard only. You must be able to reach a pause control, and with reduced motion emulated the carousel must not be advancing when the page loads.
Motion in Loading Spinners & Skeletons
Loading states are where motion and screen-reader semantics collide. A spinner conveys "work is happening," which is essential feedback—but its rotation is still motion, and the shimmer sweep on skeleton placeholders is decorative spatial movement that should stop under reduced motion. The accessible answer is to separate the visual indicator from the announcement.
The visible spinner or shimmer is decorative and should carry aria-hidden="true". The loading state belongs in a role="status" live region with sr-only text, which satisfies 4.1.3 Status Messages by announcing "Loading" once without forcing screen-reader users to perceive any animation at all. Under reduced motion, replace the shimmer sweep with a static muted background or a gentle opacity pulse rather than a sweeping gradient.
.skeleton {
background: var(--surface);
}
/* Shimmer only when motion is welcome */
@media (prefers-reduced-motion: no-preference) {
.skeleton {
background: linear-gradient(90deg, var(--surface) 25%, var(--primary-soft) 50%, var(--surface) 75%);
background-size: 200% 100%;
animation: skeleton-sweep 1.4s ease-in-out infinite;
}
}
@keyframes skeleton-sweep {
to { background-position: -200% 0; }
}
The full pattern—role="status", aria-busy, the sr-only text, the decorative spinner, and not trapping focus while content loads—is detailed in Accessible Loading Skeletons and Spinners.
Testing Hook: With reduced motion on, trigger a loading state. The shimmer sweep must stop, and a screen reader should announce "Loading" via the
role="status"region while the visual indicator stays silent underaria-hidden.
Common a11y Mistakes
- Kill-switch reduction: Setting
transition: noneeverywhere removes the feedback the animation carried. Crossfade instead of deleting—opacity is vestibular-safe. - Forgetting the change listener: Reading
matchMedia().matchesonce and never subscribing means a user who toggles the OS setting mid-session is ignored until reload. - SSR flash of motion: Defaulting the hook to
false(motion on) ships a large animation on first paint before hydration corrects it. Default to reduced. - Auto-advancing under reduce: A carousel that respects
prefers-reduced-motionfor its slide transition but still auto-rotates still violates2.2.2. Start paused. - Animated spinner without a live region: Decorative motion with no
role="status"leaves screen-reader users unaware that loading is happening; the inverse—announcing withoutaria-hiddenon the visual—causes double perception. - Gating only CSS, not JS: CSS transitions respect the media query but a
Framer Motionspring orrequestAnimationFrameloop running in parallel does not, unless you gate it on the hook orMotionConfig.
Frequently Asked Questions
Does prefers-reduced-motion mean I have to remove all animation?
No. It means the user wants minimal motion, specifically large spatial movement like slides, scale, parallax, and rotation. Opacity crossfades and essential indicators (progress, loading) are generally acceptable. Design a reduced variant that preserves feedback rather than stripping animation entirely.
Should I detect motion preference in CSS or JavaScript?
Both, for different layers. Use @media (prefers-reduced-motion) for declarative CSS transitions and keyframes, and read window.matchMedia in JavaScript to gate framework animations like Framer Motion, react-spring, or GSAP that CSS cannot reach. A motion-safe default—animation only inside no-preference—is the most resilient strategy.
What WCAG criteria govern animation and motion?2.2.2 Pause, Stop, Hide (Level A) requires controls for auto-starting motion lasting over five seconds. 2.3.3 Animation from Interactions (Level AAA) requires interaction-triggered motion be disablable unless essential. 2.3.1 Three Flashes or Below Threshold (Level A) limits flashing that can trigger seizures.
Why default my React hook to reduced motion instead of full motion?
Because the server has no window, the first render must assume a value. Assuming reduced motion means the worst-case first paint is a missing flourish; assuming full motion means a vestibular user can experience an unwanted lurch before hydration corrects it. Fail safe toward less motion.
Can a carousel auto-advance if the slide transition is just a fade?
A fade addresses the transition motion, but auto-advancing content that loops still falls under 2.2.2 Pause, Stop, Hide and competes for attention. Provide a keyboard-operable pause control, and start the carousel paused when prefers-reduced-motion: reduce is set.