July 8, 2026
React useReducedMotion Hook: Respect prefers-reduced-motion (2026)
A full-bleed parallax hero. An auto-scrolling carousel. A loading spinner that spins, bounces, and rotates. For most users that’s just “modern.” For someone with a vestibular disorder, migraine with aura, or Meniere’s disease, it can trigger real nausea, dizziness, or a headache bad enough to close the tab — not annoyance, an actual physical symptom. That’s why every major OS ships a “Reduce Motion” toggle (iOS since 2013, macOS, Windows, Android, GNOME), and why CSS exposes it to the web as the prefers-reduced-motion media feature.
useReducedMotion is the hook that reads that setting into React state — reactively, so it updates the instant the user flips the OS toggle, and safely, so it doesn’t touch window during SSR. This post covers the real @reactuses/core API, what prefers-reduced-motion actually asks you to remove (it’s narrower than “no motion at all”), and the three integration patterns you’ll actually reach for.
The Naive Version
The obvious approach is window.matchMedia inside an effect:
function Hero() {
const [reduce, setReduce] = useState(false);
useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduce(mql.matches);
// 🐛 forget this and the value never updates again
const onChange = () => setReduce(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return <div className={reduce ? 'hero' : 'hero hero--parallax'} />;
}
Three ways this goes wrong in practice. First, window doesn’t exist on the server, so this whole block needs a guard the moment you’re on Next.js, Remix, or Astro — and it’s easy to reach for typeof window !== 'undefined' sprinkled through render instead of isolating it in an effect. Second, people initialize the state once with mql.matches and skip the change listener entirely — which works fine until a user actually toggles the setting while your tab is open (switching a laptop from battery to plugged-in triggers this on some OSes, and QA almost never tests it). Third, every component that cares about motion preference reimplements this same matchMedia + listener + cleanup dance.
The API
useReducedMotion is one call:
const prefersReducedMotion = useReducedMotion(defaultState?: boolean): boolean;
defaultState— optional, defaults tofalse. This is the value returned during SSR and on the very first client render, before the media query can be evaluated.- returns — a boolean that starts as
defaultStateand updates live whenever the user’s OS-level motion preference changes. No manual listener, no cleanup to forget.
Under the hood it’s genuinely this simple — the whole implementation is a one-line call into useMediaQuery:
export function useReducedMotion(defaultState?: boolean) {
return useMediaQuery('(prefers-reduced-motion: reduce)', defaultState);
}
useMediaQuery is the hook doing the actual work — constructing the MediaQueryList inside an effect (so nothing touches matchMedia during render or on the server) and subscribing to its change event for you. useReducedMotion just pins the query string. That’s the whole value: you get the right query, spelled correctly, wired up correctly, every time.
Pattern 1: Kill a Single Animation
The smallest use — gate one CSS transition or class on the flag:
import { useReducedMotion } from '@reactuses/core';
function Hero() {
const reduce = useReducedMotion();
return (
<div className={reduce ? 'hero' : 'hero hero--parallax'}>
<h1>Welcome</h1>
</div>
);
}
.hero--parallax carries the transform: translateY(...) scroll-linked animation; the base .hero class doesn’t. No JS animation logic runs at all when reduce is true — you’re not just skipping the visual motion, you’re skipping the scroll-listener or requestAnimationFrame loop driving it, which is also a real performance win on lower-end devices.
Pattern 2: Framer Motion / GSAP Integration
If you’re using an animation library, the hook value plugs straight into its duration/transition config instead of a class swap:
import { motion } from 'framer-motion';
import { useReducedMotion } from '@reactuses/core';
function Card({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.4 }}
>
{children}
</motion.div>
);
}
Note this doesn’t remove the fade — opacity isn’t the kind of motion prefers-reduced-motion is about (see the next section). It removes the displacement: with reduce true, y never moves and the transition is instant, so the card just appears. Framer Motion also ships its own useReducedMotion hook that does the same matchMedia read — if you’re already pulling in @reactuses/core for everything else, this hook keeps you on one source of truth instead of two libraries independently reading the same media query.
Pattern 3: One Global Switch Instead of N Guards
Gating every individual animation with reduce ? ... : ... scales badly once a design system has dozens of animated components. The pattern that scales is: read the hook once, near the app root, and drive a data- attribute that a global stylesheet reacts to.
function App({ children }: { children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<div data-motion={reduce ? 'reduce' : 'no-preference'}>
{children}
</div>
);
}
[data-motion="reduce"] *,
[data-motion="reduce"] *::before,
[data-motion="reduce"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
This is the same shape as the classic CSS-only universal-selector rule built directly on the media query — the difference is the data-motion attribute is driven by React state, so the exact same flag is available to your JS animation logic (Pattern 2) and your CSS (this pattern) without reading the media query twice or risking the two getting out of sync.
What “Reduce” Doesn’t Mean
prefers-reduced-motion: reduce is not “disable all motion.” WCAG 2.3.3 and the platform vendors are specifically after large-scale, non-essential motion: parallax scrolling, autoplaying background video, zooming/panning hero animations, auto-advancing carousels, bouncing/spinning decorative loaders. Motion that carries information or confirms an interaction — a checkbox’s tick animation, a button’s brief press state, a spinner indicating “still loading,” drag-and-drop feedback — is generally fine to keep, ideally just shorter and calmer. Removing every pixel of movement, including a :hover color transition, isn’t what the setting is asking for and can make an interface feel broken rather than accessible. When in doubt, ask “does this motion span a large portion of the screen, or run continuously without user action?” — that’s the motion to gate.
One more nuance worth knowing: if you only need this in CSS and never in JS logic, Tailwind’s motion-reduce: variant (or a plain @media (prefers-reduced-motion: reduce) block) gets there with zero JavaScript. Reach for the hook specifically when the decision needs to reach into render or effect logic — choosing between an autoplaying <video> and a static poster image, skipping a scroll-driven animation library’s setup entirely, or the data-attribute pattern above.
SSR Safety
useReducedMotion is safe to render on the server. useMediaQuery only calls window.matchMedia inside an effect — which React never runs during SSR — so the server and the very first client paint both use defaultState (false unless you pass otherwise). There’s no typeof window guard to write and no hydration mismatch: React reconciles the same value on both passes, and the real preference is read and applied on the client right after mount. (For the general pattern behind this, see SSR-Safe React Hooks.)
The Preference-Query Family
useReducedMotion is one of a small set of hooks that read OS-level accessibility and display preferences — all thin, purpose-named wrappers over the same primitive:
| Hook | Media query | Returns |
|---|---|---|
useReducedMotion | prefers-reduced-motion | boolean |
usePreferredColorScheme | prefers-color-scheme | "dark" | "light" | "no-preference" |
usePreferredContrast | prefers-contrast | "more" | "less" | "custom" | "no-preference" |
usePreferredDark | prefers-color-scheme: dark | boolean |
useMediaQuery | any query you pass | boolean |
Reach for the named ones first — they exist so the query string is spelled correctly once, in one place — and drop down to useMediaQuery directly for anything more specific, like a custom breakpoint. For the wider set of hooks that make a React app respect what the user already configured at the OS level, see React and User Preferences; for the broader accessibility toolkit, see Building Accessible React Components with Hooks.
Takeaways
prefers-reduced-motionisn’t a nice-to-have — for users with vestibular disorders or migraine triggers, ignoring it can make a page physically unusable.useReducedMotion(defaultState?)reads it reactively: a one-line wrapper overuseMediaQuery('(prefers-reduced-motion: reduce)', defaultState), so it inherits SSR-safety and live updates for free.- Gate individual transitions for one-off cases (Pattern 1), feed it into Framer Motion/GSAP transition configs (Pattern 2), or drive a global
data-motionattribute once at the app root so CSS and JS share one source of truth (Pattern 3). - The setting targets large-scale or non-essential motion — parallax, autoplay, decorative loops — not every
:hovertransition. Keep short, functional motion; cut the rest. - SSR-safe by default:
defaultStatecovers the server and first paint, notypeof windowguard needed.
Grab it from @reactuses/core — it’s one hook standing between your animations and a user who can’t safely look at them.