Skip to story

Accessibility

prefers-reduced-motion Contracts

8 min read · May 31, 2026 ★ Flagship

Reading level

The animation that triggers migraines

Some users have vestibular disorders — conditions where movement on screen causes real physical symptoms: dizziness, nausea, headaches, even migraines. These users set "Reduce Motion" in their OS settings specifically to prevent this. If your site ignores that setting and plays full-scale animations, you're causing harm. The fix is a single CSS media query.

prefers-reduced-motion is an OS-level accessibility setting that browsers expose as a media query. About 25% of iPhone users have it enabled (Apple 2021 data). Ignoring it means your animated hero, page transitions, and skeleton shimmer are running uninvited for a significant portion of your audience.

WCAG 2.1 SC 2.3.3 (Animation from Interactions, AAA) requires that motion triggered by interaction can be disabled. prefers-reduced-motion is the mechanism. The "safe" default: always check the query; never put the burden on users to find an in-app toggle they shouldn't need.

Two strategies: disable vs slow-down

You have two options when prefers-reduced-motion: reduce is detected:

  1. Disable motion entirely — remove animations, use instant transitions
  2. Reduce motion — keep functional animations (state changes, loading indicators) but remove decorative spinning, parallax, and large-scale movement
/* Option 1: disable all animation */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
  }
}

/* Option 2: targeted — remove decorative, keep functional */
@media (prefers-reduced-motion: reduce) {
  .hero-parallax { transform: none !important; }
  .skeleton-shimmer { animation: none; background: var(--casebook-surface-2); }
  .page-transition { transition: none; }
}

The prefers-reduced-motion: no-preference media query targets users who have NOT set reduce motion — use it for "motion-optional" animations that are off by default:

/* Animation off by default; opt-in with no-preference */
.card { transition: none; }
@media (prefers-reduced-motion: no-preference) {
  .card { transition: transform 0.2s ease; }
}

The "safe by default" pattern — write animations with no-preference guard rather than reduce override. This ensures users who never set the preference still get motion, while reduce-preference users never see it by accident. Particularly important for skeleton shimmer, progress bar animations, and loading spinners — all of which are functional (provide information) but motion-heavy.

The hero that made her sick

Maya opens the marketing page for a new fitness app. The hero section is stunning — a full-viewport parallax that shifts background layers as she scrolls, with a large animated logo that spins and bounces into place. Within 30 seconds, she closes the tab. She has a vestibular disorder. The constant motion triggered dizziness and nausea. She had turned on "Reduce Motion" in macOS system preferences months ago, but the site never checked. She won't be back.

Technically, the parallax effect used a scroll event listener updating transform: translateY() on three background layers, plus a CSS @keyframes animation on the logo running on page load. Neither was wrapped in a prefers-reduced-motion check. The OS setting was set; the site simply ignored it.

This is a WCAG 2.1 SC 2.3.3 (Animation from Interactions, AAA) violation. The standard requires that motion triggered by interaction can be disabled — and "interaction" includes scrolling.

The business impact goes beyond one user. Apple reported ~25% of iPhone users have enabled "Reduce Motion" in Accessibility settings. That's a quarter of your iOS audience exposed to unchecked animations. For a high-traffic marketing page, ignoring this preference means a measurable fraction of visitors leave due to physically uncomfortable experiences — a retention problem that never shows in bounce rate attribution, because no one knows the real reason.

One media query, zero harm

The fix was simpler than anyone expected. The team wrapped every animation in @media (prefers-reduced-motion: no-preference) — meaning animations only run for users who haven't set the OS preference. Users with "Reduce Motion" enabled see the logo appear instantly and the hero stays static. The page still looks great. They just don't move.

Two changes were made. First, the CSS @keyframes animation on the logo was moved inside a no-preference guard. Second, the JavaScript scroll handler was gated: it reads window.matchMedia('(prefers-reduced-motion: no-preference)').matches before applying any transform. The fallback for reduced-motion users: a subtle opacity fade on scroll instead — communicates the same layered depth without vestibular risk.

/* Before: always animates */
.hero-logo { animation: bounce-in 0.6s ease; }

/* After: opt-in for non-reduced users */
@media (prefers-reduced-motion: no-preference) {
  .hero-logo { animation: bounce-in 0.6s ease; }
}

/* Reduced-motion alternative */
@media (prefers-reduced-motion: reduce) {
  .hero-logo { opacity: 0; animation: fade-in 0.2s ease forwards; }
}

The team added a Lighthouse CI check and a custom Playwright assertion that queries animation-duration on known animated elements under a simulated reduced-motion media query. Zero-duration (or none) is the pass criterion. This runs on every PR. The cost of the fix was two hours; the cost of the original bug was unknown but real. Post-fix, they added prefers-reduced-motion coverage to the design system token spec so new animated components are guarded by default.

Pattern at a glance

Annotated example: CSS animation with and without prefers-reduced-motion guard

❌ ALWAYS ANIMATES

@keyframes slide-in {
  from {
    transform: translateY(-40px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

.hero {
  animation: slide-in 0.8s ease;
}

Runs for every user — motion-sensitive users harmed

✅ MOTION OPT-IN

/* Static by default */
.hero { opacity: 1; }

/* Motion only for users who want it */
@media (prefers-reduced-motion: no-preference) {
  .hero {
    animation: slide-in 0.8s ease;
  }
}

/* Gentle fade for reduced-motion users */
@media (prefers-reduced-motion: reduce) {
  .hero { transition: opacity 0.2s; }
}

Motion is opt-in — reduced-motion users see a safe fade

Try it: animated vs motion-safe

Toggle between broken (full animation ignoring the OS setting) and fixed (honours prefers-reduced-motion). If you have "Reduce Motion" enabled on your OS, the fixed version will be static.

The demo simulates the reduce-motion state. In real usage, the CSS media query reads the actual OS setting — no JavaScript needed for the basic case.

The fixed demo uses the no-preference guard pattern — animation is off by default and only enabled via @media (prefers-reduced-motion: no-preference). This is safer than the reduce override.

⚡ Interactive demo

Implementing prefers-reduced-motion correctly

Two approaches — pick based on context:

/* Approach A: reduce override (common but opt-out) */
.card { animation: pop-in 0.3s ease; }
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
}

/* Approach B: no-preference guard (safer, opt-in) */
@media (prefers-reduced-motion: no-preference) {
  .card { animation: pop-in 0.3s ease; }
}

Key pitfall: approach A still shows a flash of the animation start before disabling it in some browsers. Approach B never starts the animation for reduced-motion users.

For JavaScript-driven animations, read the preference at runtime:

// Vanilla JS — check before animating
const prefersReduced = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (!prefersReduced) {
  element.animate([
    { transform: 'translateY(-20px)', opacity: 0 },
    { transform: 'translateY(0)', opacity: 1 }
  ], { duration: 300, easing: 'ease' });
}

// React hook pattern
function useReducedMotion() {
  const [reduced, setReduced] = React.useState(
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );
  React.useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);
  return reduced;
}

Implementation notes:

  • The change event fires if the user changes OS settings while the page is open — listen and react
  • Framer Motion's useReducedMotion() does this for you in React
  • Test in macOS: System Settings → Accessibility → Display → Reduce Motion
  • WCAG 2.3.3 is AAA — but motion-related harm is real at any compliance level; treat it as AA in practice

At design system scale, the right contract is: animated components accept a motion prop or read from a theme token. The token is set by a provider that reads prefers-reduced-motion once at app root. This prevents 40 different components each doing their own media query check.

// DS provider pattern
const MotionContext = React.createContext({ reduced: false });

function MotionProvider({ children }) {
  const reduced = useReducedMotion(); // custom hook above
  return (
    <MotionContext.Provider value=>
      {children}
    </MotionContext.Provider>
  );
}

// Component consumes — no media query inside
function AnimatedCard() {
  const { reduced } = React.useContext(MotionContext);
  return <div className={reduced ? 'card--static' : 'card--animated'} />;
}

Tooling: add a custom axe-core rule or a Playwright fixture that emulates prefers-reduced-motion: reduce and asserts animation-duration is 0s or 0.001ms on every animated element. Framer Motion and React Spring both have built-in reduced-motion support — prefer those over raw WAAPI for complex animations.

References

Remember

Key takeaways

  • Always add @media (prefers-reduced-motion: reduce) overrides for any animation that moves large elements or loops continuously.
    The "safe by default" pattern wraps animations in @media (prefers-reduced-motion: no-preference) — motion is opt-in, not opt-out.
    WCAG 2.1 SC 2.3.3 requires that motion triggered by interaction can be disabled — prefers-reduced-motion is the implementation mechanism, not a nice-to-have.
  • Skeleton shimmer, progress bar animations, and spinning loaders should all be disabled under reduce-motion — they're functional but motion-heavy.
    Keep functional state-change animations (button press feedback, form error appearance) even under reduce motion — they communicate state, not decoration.
    Add a prefers-reduced-motion test to your automated a11y checks — axe-core doesn't catch this; write a custom assertion that verifies animation-duration on animated elements.

Enjoyed this case?

Case 2 of 3 in Accessibility · 2 of 31 live

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
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.