EmpireUI
Get Pro
← Blog8 min read#reduced motion#accessibility#animation

prefers-reduced-motion Guide: Safe Animations for Vestibular Users

Learn how prefers-reduced-motion works, which animations trigger vestibular disorders, and how to write CSS and React code that's safe for every user.

developer writing accessible CSS animation code on laptop screen

Why prefers-reduced-motion Exists

Vestibular disorders affect roughly 35% of adults over 40 in the US — and that's not a fringe number. Conditions like BPPV (benign paroxysmal positional vertigo), labyrinthitis, and Meniere's disease mean that large-scale movement on screen can trigger genuine nausea, vertigo, and migraines. Not "I feel a bit off" — actual incapacitating symptoms that can last hours.

Apple shipped prefers-reduced-motion in Safari 10.1 back in 2017, and it mapped directly to the macOS accessibility setting under System Preferences > Accessibility > Display > Reduce Motion. Chrome followed in 74. Today the media query has near-universal support and maps to the OS-level setting on Windows, macOS, iOS, and Android. You have zero excuse for ignoring it in 2026.

Look, most devs treat this as a niche checkbox. It isn't. People with epilepsy, ADHD, and chronic migraine all benefit from reduced motion environments. Parallax scrolling, spinning loaders, page-slide transitions — all of these can be genuinely harmful. The question you should be asking yourself isn't "do I need to support this?" but "why am I not already doing it?"

What the Media Query Actually Does

The syntax is about as simple as CSS gets. You wrap motion-heavy declarations inside @media (prefers-reduced-motion: reduce) and override them with safer alternatives. The two possible values are no-preference (user hasn't requested reduced motion) and reduce (they have). Most guides only show you reduce — but targeting no-preference is often the cleaner pattern because you define motion as an opt-in, not an opt-out.

/* Pattern A — motion as opt-out (most common) */
.card {
  transition: transform 0.4s ease, opacity 0.4s ease;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: opacity 0.2s ease; /* keep fade, kill movement */
  }
}

/* Pattern B — motion as opt-in (safer default) */
.hero-banner {
  animation: none;
}

@media (prefers-reduced-motion: no-preference) {
  .hero-banner {
    animation: aurora-shift 8s linear infinite;
  }
}

Pattern B is the one I'd reach for when building components from scratch. You're not fighting the reduced-motion state — you're treating it as the baseline. That said, refactoring an existing codebase to Pattern B across hundreds of components is brutal, so Pattern A is fine in practice as long as you're disciplined about it.

Worth noting: prefers-reduced-motion: reduce doesn't mean "no animation." It means "less motion." Fades, color transitions, and subtle opacity pulses are all fine — the problem is translation (things moving across the screen), large scale changes, and rapid flashing. Keep that distinction in mind when you're deciding what to strip vs. what to keep.

The Dangerous Animation Patterns to Kill First

Not all animations are equal risk. Some are mildly annoying with reduced motion enabled; others actively make people sick. Here's the triage list based on actual vestibular impact research.

Parallax scrolling is the worst offender. Elements moving at different speeds while you scroll creates a persistent visual-vestibular conflict. Kill it entirely with transform: translateY(0) !important and will-change: auto. Don't try to slow it down — just turn it off. The parallax scrolling guide on the Empire UI blog covers the full implementation pattern, including the reduced-motion guard.

Large-scale slide transitions — page transitions that fly content in from 100vw left or right, full-screen modal entrances that scale from 0.5 to 1, carousels that animate 800px of horizontal distance. Any transform that covers more than roughly 30–40px of movement needs to be disabled or replaced with a cross-fade.

Auto-playing looping animations deserve specific attention. Background video, CSS keyframe loaders that spin indefinitely, and particle systems like the kind you'd build with canvas all need to respect prefers-reduced-motion. A spinning Aurora background that's beautiful for 95% of users is an accessibility failure for the other 5%. Empire UI's aurora backgrounds and the wider component library all handle this with built-in motion guards — you get safe defaults out of the box.

/* Dangerous: large translation */
@keyframes slide-in {
  from { transform: translateX(-120vw); }
  to   { transform: translateX(0); }
}

/* Safe alternative: fade only */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.modal-enter {
  animation: slide-in 0.35s ease;
}

@media (prefers-reduced-motion: reduce) {
  .modal-enter {
    animation: fade-in 0.2s ease;
  }
}

Handling prefers-reduced-motion in React

CSS media queries are great, but you're probably managing animation state in JavaScript too — especially if you're using Framer Motion, React Spring, or custom hooks. Reading the media query in JS gives you a reactive value you can use across your component tree without duplicating CSS overrides everywhere.

// useReducedMotion.ts
import { useState, useEffect } from 'react';

export function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  });

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduced;
}

That hook is reactive — if someone toggles the OS setting while your app is open, the component re-renders immediately. It also SSR-safe: the typeof window === 'undefined' guard returns false on the server so you don't hydration-mismatch yourself.

// AnimatedCard.tsx
import { useReducedMotion } from './useReducedMotion';

export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const reduced = useReducedMotion();

  return (
    <div
      style={{
        transition: reduced
          ? 'opacity 0.2s ease'
          : 'transform 0.4s ease, opacity 0.4s ease',
      }}
      className="card"
    >
      {children}
    </div>
  );
}

Framer Motion ships useReducedMotion natively since v5 — if you're already using it, just pull from there. For React Spring, pass immediate: reduced to your useSpring calls and it'll skip the animation entirely. One more thing — test this manually. Open System Preferences (or Windows Settings > Ease of Access > Display) and toggle "Reduce motion" while your dev server is running. What you see is what your users see.

Design System Integration: Making It Automatic

The real win isn't patching animations one by one — it's building reduced-motion behaviour into your design system tokens so it applies globally and consistently. CSS custom properties are your best friend here.

:root {
  --duration-fast:   150ms;
  --duration-base:   300ms;
  --duration-slow:   600ms;
  --easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --duration-fast:  0ms;
    --duration-base:  0ms;
    --duration-slow:  0ms;
  }
}

/* Every component uses the token */
.button {
  transition: background-color var(--duration-fast) var(--easing-standard);
}

.drawer {
  transition: transform var(--duration-base) var(--easing-standard);
}

Honestly, this is the cleanest approach I've found. You set duration tokens to 0ms at the system level and every single component in your codebase respects reduced motion without a single individual override. Easing still applies (it becomes an instant snap), color and opacity transitions still work if you keep a non-zero --duration-color token, and you haven't touched a single component.

This integrates directly with Empire UI's token architecture. The animation design system article covers how to structure your duration and easing tokens — highly relevant reading if you're building this into a shared component library. You should also audit your box shadow generator outputs and gradient generator usage — animated gradients and box-shadow transitions both need the same treatment.

Quick aside: don't set --duration-base: 1ms as a "safer" middle ground. Either animations run or they don't — a 1ms transition is imperceptible and just adds complexity. Set it to 0ms and be done with it.

Testing Your Implementation

There are three layers of testing you should run. OS-level manual testing, browser DevTools simulation, and automated axe-based audits. Skip any one of them and you'll ship gaps.

OS toggle: macOS — System Settings > Accessibility > Display > Reduce motion. Windows 11 — Settings > Accessibility > Visual effects > Animation effects. iOS — Settings > Accessibility > Motion > Reduce Motion. Android 12+ — Settings > Accessibility > Remove animations. Toggle each one, reload your app, and watch what happens. Pay attention to looping animations, page transitions, and hover effects.

Chrome DevTools: Open DevTools, go to the Rendering panel (⋮ > More tools > Rendering), scroll down to "Emulate CSS media feature prefers-reduced-motion" and set it to reduce. This lets you test without touching your OS settings, which is handy during development. Firefox has the same toggle under Responsive Design Mode.

# Automated testing with axe-core (add to your test suite)
npm install --save-dev @axe-core/react

# Or run a one-off audit with the CLI
npx axe http://localhost:3000 --tags wcag2a,wcag2aa

In practice, automated tools won't catch every motion issue — axe won't flag a CSS keyframe animation that covers 200px of screen movement. The OS toggle test is irreplaceable. Block out 20 minutes, go through your key user flows with reduced motion enabled, and actually feel what it's like. If something feels jarring even to you as an able-bodied developer, it's going to be far worse for someone with a vestibular disorder.

Safe Animations: What You Can Still Do

Reduced motion doesn't mean a dead, static UI. There's a whole category of animation that's safe for vestibular users and still adds polish and interactivity. The key is keeping movement small, purposeful, and user-initiated rather than automatic.

Opacity fades are universally safe. Transitioning from 0 to 1 opacity on element entry — tooltips appearing, modals fading in, skeleton loaders resolving — creates visual feedback without spatial disorientation. Keep durations under 250ms for interactive feedback, up to 400ms for larger element transitions.

Color and background transitions are safe. Button hover states that shift from one color to another, focus rings that pulse via color rather than size, theme toggles that cross-fade — all fine. The glassmorphism components on Empire UI use color-only hover transitions precisely because they look great and sidestep motion concerns entirely.

Small-scale transforms under 10px are generally safe. A button that shifts 2–4px on press, a card that lifts 4px on hover — these are close enough to imperceptible physical feedback that they don't trigger vestibular symptoms. The boundary is roughly 5–10px of translation; beyond that, reduce or eliminate under prefers-reduced-motion: reduce.

/* Safe: small lift on hover */
.card {
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
  transform: translateY(-4px); /* safe at 4px */
  box-shadow: 0 12px 24px rgba(0,0,0,0.12);
}

@media (prefers-reduced-motion: reduce) {
  .card:hover {
    transform: none; /* just in case; 4px might be fine but be safe */
    box-shadow: 0 4px 8px rgba(0,0,0,0.16); /* different shadow = still feedback */
  }
}

FAQ

Does prefers-reduced-motion disable all animations?

No — it signals that the user prefers less motion, not zero animation. Opacity fades, color transitions, and small-scale feedback (under ~5px of movement) are still appropriate. Large translations, parallax effects, and looping kinetic animations are what you should remove.

How do I test prefers-reduced-motion without changing my OS settings?

Chrome DevTools > More tools > Rendering > Emulate CSS media feature prefers-reduced-motion. Firefox has an equivalent toggle in its Responsive Design Mode. Both simulate the reduced-motion state instantly without touching system preferences.

Should I use prefers-reduced-motion: reduce or prefers-reduced-motion: no-preference?

Targeting no-preference is cleaner for new code — you define motion as opt-in, so your base styles are already safe. Targeting reduce works fine for existing codebases where adding motion overrides is easier than refactoring base styles.

Is Framer Motion safe to use with prefers-reduced-motion?

Yes — Framer Motion exports a useReducedMotion hook since v5 that reads the OS preference. Pass the result to animate or transition props to skip motion-heavy variants. You still need to design the reduced-motion fallback yourself; the hook just surfaces the preference.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Animation in Design Systems: Tokens, Reduced Motion, ChoreographyPrint Stylesheets in 2026: @media print Best Practicestailwindcss-motion Plugin: Declarative Animations in TailwindTailwind Scroll Animations: @starting-style and animation-timeline