prefers-reduced-motion: the right way to ship animations
Animation that ignores prefers-reduced-motion is an accessibility bug. Here is how to honour it properly in React without shipping two of everything.
21 May 2026 · 6 min read
Some people get dizzy, nauseous, or genuinely unwell from on-screen motion. Vestibular disorders, migraine, and motion sensitivity are common, and large parallax moves, sudden zooms, and endless looping animation can trigger real symptoms. For those users the operating system offers a setting - "reduce motion" - and the browser exposes it to you as the prefers-reduced-motion media query. Shipping animation that ignores it is not a missing nicety. It is an accessibility bug, and it is covered by WCAG.
The good news: honoring it properly is a small amount of code, and you do not need to maintain two versions of every component.
Layer one: the CSS safety net
Start with a global CSS rule. This is your floor - it catches every transition and animation on the page, including ones in third-party code and ones you forget about later.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}The near-zero durations effectively disable CSS animation and transitions while still letting any code that listens for transitionend fire. animation-iteration-count: 1 stops infinite loops - marquees, spinners, pulsing glows - from running forever. This rule alone fixes a large share of motion problems on most sites. But it is a blunt instrument: it cannot tell a harmless fade from a nauseating parallax, so it kills both. That is what the second layer is for.
Layer two: per-component control in React
For JavaScript-driven animation - anything with Framer Motion, scroll handlers, or pointer effects - the CSS rule does not apply. You need to read the preference in code. Framer Motion gives you useReducedMotion(), a hook that returns a boolean and re-renders your component if the user changes the setting.
The mistake to avoid is branching into two separate component trees - one animated, one static. That doubles your surface area and the two drift out of sync. Instead, keep one component and branch only the animation props:
import { motion, useReducedMotion } from "framer-motion";
function FadeUp({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.2 : 0.5 }}
>
{children}
</motion.div>
);
}One component, one tree. When reduce is true the element still fades in - it just does not travel. The markup, the props, the children are all identical; only the motion values change. This scales to anything: a parallax hook can return a zero offset, a tilt effect can skip binding its pointer listener, a looping background can render a static frame.
Reduced does not mean none
This is the part teams get wrong in both directions. "Reduce motion" does not mean strip every trace of animation - it means remove the kinds of motion that cause trouble. The distinction is about vestibular triggers, not animation in general.
Keep: opacity fades, color and background transitions, small instant state changes like a button hover. These do not move through space and do not cause discomfort. Removing them just makes the interface feel broken and abrupt.
Drop or neutralize: large translate and scale moves, parallax, continuous looping motion, spinning, anything that flies across the viewport, and motion tied to scroll position. These are the vestibular triggers. A reduced-motion experience should still feel finished and intentional - calmer, not stripped.
A practical checklist
- Ship the global CSS
@mediarule once, app-wide. It is your safety net for everything you do not control. - For Framer Motion and JS effects, read
useReducedMotion()and branch the animation props - never fork the component. - When reduced, keep fades and color transitions; remove transforms, parallax, and loops.
- Test it for real: toggle "Reduce motion" in your OS accessibility settings, or emulate the media feature in your browser's dev tools, and walk the page.
Every animated component in shadcn Motion Blocks already does this - the hero aurora, constellation background, and confetti burst all read the preference and quiet themselves down rather than shutting off, so honoring it is the default rather than an afterthought.
Components in this article
Want the whole pack?
Every component in this article ships in shadcn Motion Blocks - one file each, tunable in a live studio. €19 for all of them.
See pricing