prefers-reduced-motion & Motion Accessibility
Motion can inform and delight, but for millions of users it triggers vertigo, seizures, or cognitive overload — here's how to design animation that respects everyone.
7 min read
Card entrance
Respect the OS prefers-reduced-motion setting: replace large or looping motion with a minimal fade. Never remove feedback entirely — just the intensity.
The full lesson
Animation is one of the most expressive tools in UI design. It communicates state, directs attention, and adds personality. But for many users — those with vestibular disorders, epilepsy, ADHD, migraine sensitivities, or situational constraints — the same motion that delights one person can cause real physical harm or mental overload in another.
prefers-reduced-motion is an OS-level signal that tells you a user wants less motion. Honoring it lets you protect those users without stripping all dynamism from your product.
This lesson covers how to detect and respond to that signal correctly, how to tell “reduced” apart from “none,” and how to build a motion accessibility strategy that satisfies WCAG 2.2 and still ships a product with character.
Why Motion Accessibility Is Not Optional
Vestibular disorders affect roughly 35% of adults over 40 in the United States, according to the National Institutes of Health. The inner ear’s balance system is sensitive to large-scale parallax, rapid zoom, and looping translation animations — the same techniques commonly used for hero transitions and onboarding sequences. When triggered, symptoms include nausea, vertigo, and headaches that can last for hours.
Photosensitive epilepsy is less common but far more severe. Content that flashes more than three times per second across a large area of the screen can trigger seizures. WCAG 2.2 Success Criterion 2.3.1 (Three Flashes or Below Threshold) addresses this at Level A — meaning it is the minimum legal requirement, not an optional stretch goal.
ADHD and anxiety disorders are also affected by animation. Looping or autoplaying motion competes for attention. It makes focused reading and task completion harder for users whose attention is already stretched.
The prefers-reduced-motion Media Query
The prefers-reduced-motion CSS media query exposes the system-level “Reduce Motion” setting available on macOS, iOS, Windows, and Android. It has two values: no-preference (the default when the user has not opted in) and reduce.
@media (prefers-reduced-motion: reduce) {
/* styles for users who prefer reduced motion */
}
The modern best practice flips the default. Write full motion for no-preference, then scale it back under reduce. This is sometimes called the “motion-first” approach. It mirrors how dark-mode token overrides work: the base layer carries full fidelity, and the override layer strips or softens it.
.hero-card {
transition: transform 400ms cubic-bezier(0.34, 1.56, 1, 1), opacity 300ms ease;
}
@media (prefers-reduced-motion: reduce) {
.hero-card {
transition: opacity 200ms ease;
/* transform removed; opacity fade is safe */
}
}
In JavaScript, you read the same signal with matchMedia:
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const prefersReduced = motionQuery.matches;
motionQuery.addEventListener('change', (e) => {
if (e.matches) {
pauseAllAnimations();
} else {
resumeAnimations();
}
});
Listening for change events matters because users can toggle the OS setting while your app is open. Any reactive framework should update without requiring a page reload.
Reduced vs. None: A Critical Distinction
A common mistake is treating prefers-reduced-motion: reduce as “remove all animation.” The W3C spec intentionally says “reduce,” not “eliminate.” Some motion is still appropriate — and sometimes expected — even when the preference is set.
| Animation type | Under reduce | Reasoning |
|---|---|---|
| Opacity fade (short, subtle) | Keep or shorten | Not vestibular-triggering |
| Large-scale parallax / zoom | Remove | High vestibular risk |
| Spinning loaders | Replace with opacity pulse | Looping rotation is risky |
| Slide-in panels (far translation) | Replace with fade | Translation over large distance triggers vertigo |
| Ripple/press feedback | Keep, shorten | Small-scale, user-initiated |
| Skeleton screen shimmer | Keep, desaturate | Low amplitude, communicative |
| Autoplaying hero video | Pause | Must pause per WCAG SC 2.2.2 |
| Scroll-driven progress indicators | Keep if opacity-only | Remove if position-driven |
The guiding principle: remove translations, spins, and zooms. Keep fades, subtle scale changes, and color transitions. The goal is to preserve clarity and feedback while eliminating vestibular triggers.
Motion Tokens and the Reduced-Motion Layer
If your design system uses motion tokens (duration, easing, distance), you can build a reduced tier directly into the token set. This follows the W3C Design Token Community Group (DTCG) format.
{
"motion": {
"duration": {
"standard": { "$value": "300ms", "$type": "duration" },
"standard-reduced": { "$value": "150ms", "$type": "duration" }
},
"easing": {
"spring": { "$value": "cubic-bezier(0.34, 1.56, 1, 1)", "$type": "cubicBezier" },
"fade": { "$value": "ease", "$type": "cubicBezier" }
},
"distance": {
"slide": { "$value": "24px", "$type": "dimension" },
"slide-reduced": { "$value": "0px", "$type": "dimension" }
}
}
}
At the component level, CSS custom properties make swapping those values simple:
:root {
--motion-slide-distance: 24px;
--motion-duration-standard: 300ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-slide-distance: 0px;
--motion-duration-standard: 150ms;
}
}
.modal {
transform: translateY(var(--motion-slide-distance));
transition: transform var(--motion-duration-standard) ease,
opacity var(--motion-duration-standard) ease;
}
This approach makes motion accessibility a design-system concern, not a component-by-component patch. Any new component that uses system tokens inherits the correct behavior automatically.
Scroll-Driven and View Transition Considerations
Scroll-driven animations (CSS animation-timeline: scroll() and view-timeline) and the View Transitions API both need reduced-motion treatment.
For scroll-driven animations, the safest approach is to gate the entire animation block inside the no-preference query:
@media (prefers-reduced-motion: no-preference) {
.reveal-on-scroll {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
}
Writing the animation only inside the no-preference block means it never registers for reduce users. This is cleaner than adding an empty override.
For the View Transitions API, Chrome and Safari both honor prefers-reduced-motion for the default cross-fade. However, custom ::view-transition-* rules do not inherit this behavior automatically. You must wrap custom keyframes in the same media query:
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(hero-image) {
animation: slide-out 300ms ease forwards;
}
::view-transition-new(hero-image) {
animation: slide-in 300ms ease forwards;
}
}
Without this guard, your shared-element transitions will fire for everyone regardless of OS preferences.
Autoplay, Looping, and WCAG 2.2
WCAG 2.2 Success Criterion 2.2.2 (Pause, Stop, Hide) at Level A requires a user control for any moving, blinking, or scrolling content that all three of the following are true: it starts automatically, it lasts more than five seconds, and it appears alongside other content.
This applies to:
- Hero background videos and animated banners
- Looping GIFs or Lottie animations in marketing headers
- Infinite carousels and ticker tapes
- Animated illustrations in onboarding modals
A common antipattern is the decorative looping animation with no way to pause it. Beyond the WCAG failure, looping motion divides cognitive load every time the loop restarts — it competes directly with reading the adjacent content.
Respecting prefers-reduced-motion complements a pause control; it does not replace one. Some users with vestibular conditions have not yet found the OS setting, or they use a shared device where they cannot change it. Always provide an explicit pause mechanism for any auto-playing animation that exceeds five seconds.
Do
prefers-reduced-motion: reduce at both the CSS and JS layers. Replace large translations and spins with short fades. Shorten durations by 40–60%. Provide an explicit pause button for autoplay video and looping animations regardless of the OS setting. Use motion tokens with a reduced tier so every component inherits the right behavior automatically.Don't
reduce is active — some motion is still appropriate and expected. Treat prefers-reduced-motion as a substitute for WCAG 2.2.2 pause controls. Animate width, height, top, or left (they trigger layout and should be avoided even for non-reduced-motion users). Use looping animated GIFs or Lottie files in persistent UI chrome with no pause affordance.Testing and Auditing Motion Accessibility
Testing reduced-motion behavior requires more than toggling the OS setting once. A thorough motion accessibility audit covers five areas.
-
OS toggle test — Enable “Reduce Motion” on macOS (System Settings > Accessibility > Display) and iOS (Settings > Accessibility > Motion). Walk every screen. Verify that no large translations, spins, or parallax effects fire.
-
CSS DevTools — Chrome DevTools’ Rendering panel has an “Emulate CSS media feature prefers-reduced-motion” option. You can toggle it without changing system settings, which is useful for CI visual regression snapshots.
-
JavaScript event test — Use the DevTools console to call
window.matchMedia('(prefers-reduced-motion: reduce)').matchesand confirm your JS-controlled animations (GSAP, Framer Motion, etc.) respond correctly. -
Flash and flicker check — Run animations at a slower speed via an
animation-duration: 100soverride and watch for any flash or blink. Photosensitive triggers are often invisible at normal speed. -
Axe and Lighthouse — Neither tool covers motion heuristics well today, so manual review is essential. The Axe-core rule
prefers-reduced-motionchecks for@media (prefers-reduced-motion)presence but does not verify semantic correctness.
For Framer Motion (a common React animation library), the useReducedMotion hook reads the media query and returns a boolean:
import { useReducedMotion } from 'framer-motion';
function AnimatedCard() {
const reduced = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduced ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0.15 : 0.4 }}
/>
);
}
This keeps animation logic colocated with the component and avoids scattering @media overrides across a stylesheet.
User-Controlled Motion Preferences In-App
The OS setting is the primary signal, but some users want fine-grained control per application. Providing an in-app motion toggle is a best practice for content-heavy applications, games, and data dashboards where animation is structurally integrated.
Store the preference in localStorage or a user profile. On first visit, sync it with the OS default. Expose a clear toggle in the accessibility or display settings panel. The in-app value should take precedence over the OS signal when the user has explicitly set it.
function getMotionPreference() {
const stored = localStorage.getItem('motion-preference');
if (stored) return stored; // 'full' | 'reduced'
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'reduced'
: 'full';
}
This pattern respects the OS default for new users while giving experienced users explicit control — the same principle behind font size preferences in reading-focused apps.