Motion can cause genuine physical harm. For some users with vestibular disorders, a page that auto-scrolls or zooms can trigger vertigo, nausea, and migraines. For many more, constant background animation is a cognitive tax that fragments attention. Building a motion system without thinking about these users isn't just inconsiderate; it's an accessibility failure that browsers now let users correct with a single setting. Respect it.
#The idea
Every major OS ships a reduce motion preference. On macOS and iOS it's under Accessibility, Display. On Windows it's in Settings, Ease of Access. Users who enable it are telling you: your animations are causing me a problem. The correct response is to disable or drastically simplify your motion. Not to ignore the preference and hope they get used to it.
The browser exposes this as a media query: @media (prefers-reduced-motion: reduce). JavaScript can read it via window.matchMedia('(prefers-reduced-motion: reduce)').matches. Motion provides the useReducedMotion() hook.
The right approach has three layers, because no single technique catches everything:
- CSS layer: a global rule that near-disables all CSS transitions and animations when the preference is set. This catches every
hovertransition, every Tailwindtransition-colors, every@keyframesanimation. - JavaScript animation layer: Motion animations don't read CSS
transition-duration, so they need their own guard. UseuseReducedMotion()and pass{ duration: 0 }when the user prefers reduced motion. - Timer layer: auto-playing animations (loops that fire every few seconds) aren't technically animations from the browser's perspective; they're
setIntervalcalls that flip state. Even if the animations themselves are disabled, the timers keep firing. Gate them explicitly onuseReducedMotion().
Beyond reduced-motion, there's a broader principle: restraint. The single best accessibility gift you can give users is to animate less. The frequency rule applies here. Motion users will see 100 times a day should be barely there or absent entirely.
#When it applies
- Every animation you ship. This is a baseline, not a feature.
- Especially: parallax, auto-scrolling, zoom effects, and any motion over 300ms. These are the biggest vestibular triggers.
- High-frequency motion: hover states, button feedback. These get enforced by CSS alone if you use native
transition-durationproperties.
#When it doesn't
- Never. There is no case where you should ignore
prefers-reduced-motion. - The only permitted exception: a motion-focused teaching tool that explicitly asks the user show me motion examples. Moro is such a tool, and even Moro disables auto-loops under reduced motion and lets users explore demos on demand.
#Implementation
The CSS safety net. Add once, covers every CSS transition/animation.
@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;
}
}Motion animations. Use the useReducedMotion() hook.
import { motion, useReducedMotion } from "motion/react"
function Card() {
const shouldReduceMotion = useReducedMotion()
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
/>
)
}Timers and auto-loops. Gate the effect itself.
function LoopingDemo() {
const shouldReduceMotion = useReducedMotion()
const [isAnimating, setIsAnimating] = useState(false)
useEffect(() => {
if (shouldReduceMotion) return // don't start the loop
const interval = setInterval(() => {
setIsAnimating(true)
setTimeout(() => setIsAnimating(false), 800)
}, 2200)
return () => clearInterval(interval)
}, [shouldReduceMotion])
// ...
}#Craft notes
- Don't just disable, simplify. Sometimes
initial={false}(skip enter animation) plus instant snap is a better answer than nothing, because the user still perceives state changes. - Test with the OS setting enabled. Don't rely on a toggle in your own app. Flip the OS setting and use your site.
- Touch devices trigger hover on first tap. Wrap hover-lift animations in
@media (hover: hover)so they only fire on true pointer devices. - Frequency is accessibility. An animation users see 100 times a day should be nearly invisible. High-frequency motion is attention tax, and attention tax is its own form of exclusion.
useReducedMotion()returns null during SSR. Treat null as "unknown, don't animate yet" if you want to avoid hydration flashes.
#Exercises
- Enable reduce-motion and browse. Turn on Reduce Motion in your OS. Spend 15 minutes browsing major sites (Apple, Google, Linear, Figma, your own). Note which ones handle it gracefully and which don't.
- Audit your own site. With Reduce Motion enabled, find every animation that still plays at full speed. Those are bugs.
- Measure frequency. Count how many times a day you'll see a given animation. Anything above 50/day (the hover states, button presses, menu opens) should be under 150ms or disabled.
#Study this in the wild
- Apple's own marketing sites. Enable Reduce Motion and reload a product page. Almost everything collapses to instant. This is the bar.
- Moro itself. This library implements all three layers. Toggle Reduce Motion, demos stop looping, transitions collapse, but the content stays navigable.