Animation Motion
@media (prefers-reduced-motion)
Reading level
The animation that makes users sick
A user with a vestibular disorder visits your site. Your hero section has a parallax scroll effect — layers moving at different speeds as the page scrolls. They've turned on "Reduce Motion" in their OS settings, but your CSS ignores it. Within seconds, they're dizzy. They close the tab.
The fix is four lines of CSS. The prefers-reduced-motion media query reads the OS setting and lets you serve different styles to users who've asked for less motion. Ignoring it isn't a UX choice — it's an accessibility failure.
An estimated 35% of adults experience some degree of vestibular disorder. The macOS/iOS "Reduce Motion" accessibility setting is the user's opt-out signal. WCAG 2.1 SC 2.3.3 (Level AAA) requires that animations triggered by interaction can be disabled. The prefers-reduced-motion media query is the CSS mechanism for honoring both the user preference and the WCAG criterion.
The important nuance: prefers-reduced-motion: reduce means "the user has requested reduced motion," not "no motion at all." A subtle crossfade replacing a slide animation is typically acceptable; a full freeze of all animation is overly cautious. The spec intent is to reduce vestibular-triggering motion (parallax, rapid transforms, spinning) while allowing gentle transitions that aid comprehension.
Two strategies — pick one and be consistent
Strategy 1: no-preference guard (conservative) — animations only run when the user has not set a motion preference. Motion is off by default; the OS preference to "no preference" enables it.
/* ✅ Strategy 1: only animate when no preference is set */
@media (prefers-reduced-motion: no-preference) {
.hero {
animation: slideIn 0.6s ease-out;
}
.card {
transition: transform 0.2s ease;
}
}
Strategy 2: reduce override (progressive) — animations are on by default; the reduce preference disables them. Requires a comprehensive override for every animation.
/* Strategy 2: animate normally, override in reduce */
.hero {
animation: slideIn 0.6s ease-out;
}
.card {
transition: transform 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.hero { animation: none; }
.card { transition: none; }
/* must remember every animated element */
}
Strategy 1 (no-preference guard) is easier to maintain: animations are inherently opt-in, so you never accidentally ship a new animation without the reduced-motion protection. Strategy 2 (reduce override) requires discipline — every new animation needs a corresponding override in the reduce block.
For large codebases, a single global override can cover most cases:
/* Nuclear option: disable all transitions + animations in reduce */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
This is safe as a starting point but too aggressive for production — it also disables helpful transitions like focus rings that are not motion-sensitive.
The WCAG requirement breakdown:
- SC 2.3.1 (AA): nothing flashes more than 3 times per second (photosensitive seizures)
- SC 2.3.3 (AAA): animations triggered by interaction can be disabled unless essential
Parallax effects are especially problematic for vestibular disorder users because they create a sense of depth that conflicts with physical stillness — the brain's spatial model is contradicted by the visual signal. Any CSS that creates depth parallax through transform: translateZ, layered background scroll rates, or multi-layer fixed positioning should be gated behind prefers-reduced-motion: no-preference.
Three arrows, no sequence
A design team built an onboarding flow for a productivity app. Three animated arrows guided users through the steps: arrow 1 pointed to "Create a workspace," arrow 2 faded in to point to "Invite your team," arrow 3 appeared last to point to "Import your data." The sequence was clear, engaging, and well-tested. Before launch, a developer added @media (prefers-reduced-motion: reduce) { animation: none } — a one-line fix that seemed to handle accessibility.
What users with Reduce Motion enabled actually saw: all three arrows visible simultaneously, stacked in the same position, all pointing to the same UI area at once. The CSS had removed the animation timing — which was the only mechanism that gave each arrow its separate moment. Without the animation, all three rendered at full opacity in their final positions, overlapping and illegible. The "accessible" version was less usable than having no fallback at all.
The root problem was that the animation was not an enhancement — it was the content. The sequence information lived entirely in the animation timing. Removing the animation without replacing it with a static representation of the same information left users with decorative elements and no instructional content. The fix is not to remove the animation; it is to provide a static alternative that communicates the same sequence through visual hierarchy rather than time.
Static numbered list as the canonical fallback
The correct reduced-motion version replaced the three animated arrows with a static numbered list: "1. Create a workspace, 2. Invite your team, 3. Import your data." The animated version became the enhancement; the static list became the base layer. Users with Reduce Motion enabled received a clear, sequential, fully comprehensible onboarding experience without motion of any kind.
The implementation used the no-preference guard strategy: the animated elements only rendered inside @media (prefers-reduced-motion: no-preference). The static list was always in the DOM, hidden by CSS only when motion was permitted. This meant the animated version was an enhancement layered on top of working content — the correct architecture for any progressive enhancement.
The broader principle: before adding an animation, ask what information it conveys and whether that information exists somewhere without the animation. Animations that convey sequence, state change, relationship, or spatial context must have a static equivalent. Animations that convey energy or delight — a bouncy button, a satisfying toggle — have a lower bar because removing them leaves a still-functional element, not an information gap.
Pattern at a glance
/* ❌ Only removes animation — content relies on timing for sequence */
.step-arrow { animation: fadeInPoint 0.6s ease forwards; }
.step-arrow:nth-child(2) { animation-delay: 1.2s; }
.step-arrow:nth-child(3) { animation-delay: 2.4s; }
@media (prefers-reduced-motion: reduce) {
.step-arrow { animation: none; }
/* All 3 arrows render at once, overlapping — unusable */
}
/* ✅ Static fallback always in DOM; animation wraps enhanced version */
/* Static numbered list — always visible */
.onboarding-steps-list { display: block; }
.onboarding-arrows { display: none; }
/* Animated arrows only when motion is OK */
@media (prefers-reduced-motion: no-preference) {
.onboarding-steps-list { display: none; }
.onboarding-arrows { display: block; }
.step-arrow { animation: fadeInPoint 0.6s ease forwards; }
.step-arrow:nth-child(2) { animation-delay: 1.2s; }
.step-arrow:nth-child(3) { animation-delay: 2.4s; }
}
Try it: motion without guard vs motion safe
"No guard" shows a slide animation that runs regardless of your OS motion setting. "Motion safe" wraps the animation in a no-preference query — if your OS has Reduce Motion on, no animation plays. Toggle your OS setting (System Preferences → Accessibility → Motion) to see the difference.
In the motion-safe version, the CSS media query is doing the work — no JavaScript, no class toggling, no matchMedia listener. The browser reads the OS preference at paint time and applies the right styles. This is the correct default approach.
For JavaScript-driven animations (Web Animations API, Framer Motion, GSAP), use window.matchMedia('(prefers-reduced-motion: reduce)').matches to read the preference and conditionally skip the animation. Add a change listener to respond to OS preference changes mid-session.
Showing: Motion safe — respects OS
Implementation depth
The prefers-reduced-motion media query can be read in JavaScript using window.matchMedia. Add a change listener to respond to users toggling the setting mid-session — this matters for accessibility users who may adjust OS settings while using the app. The listener fires synchronously when the OS preference changes, so you can immediately swap states.
// JS check for motion preference — use for JS-driven animations
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
function applyMotionPreference() {
if (mq.matches) {
// Stop or skip JS animations
gsapTimeline.pause();
gsapTimeline.seek(gsapTimeline.duration()); // jump to end state
} else {
gsapTimeline.play();
}
}
applyMotionPreference(); // on load
mq.addEventListener('change', applyMotionPreference); // on change
Vestibular disorder context and design principles for reduced-motion fallbacks:
- Classify animations by vestibular risk — parallax, spinning, zooming, and large-area translates are high-risk; opacity crossfades and color transitions are low-risk. High-risk animations need a full static fallback; low-risk animations can run in reduced-motion mode.
- Jump-to-end state — when removing animation, always show the end state of the animation, not the start state. A collapsed menu should appear fully open; a fade-in element should appear fully visible. The start state is often invisible or partial.
- Test with DevTools emulation — Chrome DevTools → Rendering panel → "Emulate CSS media feature prefers-reduced-motion" lets you test without changing OS settings. Add this to your visual regression test matrix.
- WCAG 2.1 SC 2.3.3 (AAA) requires animations triggered by interaction to be disableable. If your animation plays on scroll, click, or hover, Reduce Motion must stop it. Decorative background animations (not interaction-triggered) fall under the AAA criterion and are strongly recommended to respect but not strictly required at AA.
References
Remember
Key takeaways
-
Wrap CSS animations in @media (prefers-reduced-motion: no-preference) { } so they only run when the user hasn't set a motion preference. Four lines protects every user who needs it.Two strategies: no-preference guard (animations opt-in, easier to maintain) vs reduce override (animations on by default, requires comprehensive overrides). Pick one and apply it consistently.WCAG 2.1 SC 2.3.3 (AAA) requires interaction-triggered animations can be disabled. Parallax is the highest-risk animation type for vestibular disorders — always gate it behind no-preference.
-
Test with the OS "Reduce Motion" setting on — not just off. Your browser DevTools can emulate it (Rendering panel → Emulate CSS media feature).For JS animations (GSAP, Framer Motion, Web Animations API), check window.matchMedia('(prefers-reduced-motion: reduce)').matches and skip or simplify the animation accordingly.Reduced motion means "reduce vestibular-triggering motion" — not "no animation at all." Gentle crossfades and opacity transitions are appropriate in reduce mode; translates, scales, rotates, and parallax are not.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed