UI/UX Atlas
Motion & Animation Intermediate

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

Interactive example · prefers-reduced-motion

Card entrance

New message from Ada
Default: a springy slide-and-scale entrance. Expressive, but can cause discomfort for motion-sensitive users.

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 typeUnder reduceReasoning
Opacity fade (short, subtle)Keep or shortenNot vestibular-triggering
Large-scale parallax / zoomRemoveHigh vestibular risk
Spinning loadersReplace with opacity pulseLooping rotation is risky
Slide-in panels (far translation)Replace with fadeTranslation over large distance triggers vertigo
Ripple/press feedbackKeep, shortenSmall-scale, user-initiated
Skeleton screen shimmerKeep, desaturateLow amplitude, communicative
Autoplaying hero videoPauseMust pause per WCAG SC 2.2.2
Scroll-driven progress indicatorsKeep if opacity-onlyRemove 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

Detect 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

Remove all animation blindly when 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.

  1. 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.

  2. 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.

  3. JavaScript event test — Use the DevTools console to call window.matchMedia('(prefers-reduced-motion: reduce)').matches and confirm your JS-controlled animations (GSAP, Framer Motion, etc.) respond correctly.

  4. Flash and flicker check — Run animations at a slower speed via an animation-duration: 100s override and watch for any flash or blink. Photosensitive triggers are often invisible at normal speed.

  5. Axe and Lighthouse — Neither tool covers motion heuristics well today, so manual review is essential. The Axe-core rule prefers-reduced-motion checks 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.