TL;DR Handle prefers-reduced-motion with CSS first: a media query or Tailwind's motion-safe and motion-reduce variants. Use a useSyncExternalStore hook only when JavaScript drives the animation, and wrap Motion in MotionConfig with reducedMotion set to user. Swap slides for fades instead of removing feedback.
prefers-reduced-motion is the media query that tells you a user asked their operating system for less motion. In React you can read it three ways: CSS (@media (prefers-reduced-motion: reduce) or Tailwind's motion-reduce: and motion-safe: variants), a small hook built on useSyncExternalStore for JavaScript-driven animation, and Motion's <MotionConfig reducedMotion="user"> if you already use Motion. Use CSS wherever you can. Reach for the hook only when JavaScript runs the animation, like a canvas loop or a scripted sequence.
The goal is less motion, not no motion. A fade still tells someone a toast appeared. A 40 px slide, a parallax layer or a spinning logo is what causes trouble for people with vestibular disorders.
Below are the patterns I use in Next.js with Tailwind CSS v4, each run in headless Chromium with reduced motion on and off.
It reports the user's system setting: Reduce Motion on iOS and macOS, animation effects turned off on Windows, and similar switches on Android and some Linux desktops. The query has two values, no-preference and reduce (MDN). It can change while the page is open, so code that reads it once on mount goes stale.
The WCAG criterion behind it is 2.3.3 Animation from Interactions. It's level AAA, so most audits won't fail you for skipping it, but the users who need it notice right away.
Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.
Swap movement for a fade inside the media query instead of deleting the animation:
css
/* Movement by default */.toast { animation: toast-in 300ms ease-out;}@keyframes toast-in { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: none; }}/* Reduced motion: keep the fade, drop the slide */@media (prefers-reduced-motion: reduce) { .toast { animation-name: toast-fade; }}@keyframes toast-fade { from { opacity: 0; } to { opacity: 1; }}
The blunt version you'll find in many resets sets every animation-duration and transition-duration to 0.01ms !important under reduce. It works as a safety net, and the tiny duration still fires animationend and transitionend, so code waiting on those events keeps working. The cost is that a toast pops in with no fade at all, so I keep the reset for third-party CSS and write the fade by hand for my own components.
Tailwind compiles motion-safe: to @media (prefers-reduced-motion: no-preference) and motion-reduce: to @media (prefers-reduced-motion: reduce) (Tailwind docs).
Use motion-safe: for anything decorative that loops, so it only runs when the user has no preference. Use motion-reduce: to switch off a transition you added by default. In my test, the spinner's computed animation-name was spin with no preference and none with reduce, and the button's transition-property went from transform, translate, scale, rotate to none.
When JavaScript drives the animation, read the query in JavaScript. Most hooks you'll find use useState plus useEffect, which renders once with the wrong value and then corrects itself. useSyncExternalStore is React's API for subscribing to a browser value like this one (React docs):
getServerSnapshot returns false because the server can't see the user's settings. During hydration React uses that server value, then re-renders with the real one, so you get no hydration mismatch error. The change listener keeps it live. I server-rendered a page with this hook, hydrated it in Chromium with reduced motion on, and got reduced after hydration with zero recoverable errors. Flipping the emulated setting while the page was open switched the value both ways without a reload.
If the same pattern is new to you, I covered getServerSnapshot and hydration in more depth in the useSyncExternalStore post.
Per the Motion docs, with reduced motion on, transform and layout animations are disabled while opacity and colour animations still run. That matched what I measured: halfway through a 2-second test animation with reduced motion on, the toast's opacity was 0.38 and its transform was none. With no preference, it was at 0.38 opacity and still 25 px below its final spot. The toast still fades in. It just doesn't travel.
The default is "never", so without the wrapper Motion ignores the setting. Motion also logs a console message when it sees reduced motion turned on, pointing to its troubleshooting page. That message is informational, not an error in your code.
For finer control inside one component, Motion exports a useReducedMotion() hook too.
CSS media queries can't reach a canvas. Use the hook and draw one still frame instead of starting the loop:
tsx
"use client";import { useEffect, useRef } from "react";import { usePrefersReducedMotion } from "./usePrefersReducedMotion";export function Particles() { const ref = useRef<HTMLCanvasElement>(null); const reduce = usePrefersReducedMotion(); useEffect(() => { const canvas = ref.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; let frame = 0; let raf = 0; const draw = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#c6f432"; for (let i = 0; i < 40; i++) { const x = (i * 37 + frame) % canvas.width; ctx.fillRect(x, (i * 53) % canvas.height, 3, 3); } }; if (reduce) { draw(); // one still frame, no loop return; } const tick = () => { frame++; draw(); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [reduce]); return <canvas ref={ref} width={320} height={120} />;}
Because reduce is in the effect's dependency list, turning the setting on mid-session cancels the running loop, and turning it off starts it again. In my test the canvas pixels changed between two reads 300 ms apart with no preference, and stayed identical with reduce.
WebGL backgrounds, particle fields (like a Three.js particle hero) and shader effects need this the most. They're the heaviest motion on most pages and the easiest to forget.
In Chrome DevTools, open the Command Menu, run "Show Rendering", and set "Emulate CSS media feature prefers-reduced-motion" to reduce. To keep it from regressing, add a Playwright test:
Loops and decorative motion use motion-safe: or stop under reduce.
Slides, zooms and parallax become fades under reduce.
Feedback that carries meaning stays visible: a fade, a colour change, a check mark.
JavaScript animation reads the live hook, not a value captured on mount.
Canvas and WebGL draw a still frame.
There's a Playwright test with reducedMotion: "reduce".
I run this checklist on the components I make at qodesign. A good one to practise on is the Day / Night toggle: its sun-to-stars transition is exactly the kind of movement worth swapping for a crossfade. You can grab a component with full source from /free, and the hold-to-delete button is a good test of rule 3, because its press progress is feedback you shouldn't remove.
Use window.matchMedia("(prefers-reduced-motion: reduce)") inside a useSyncExternalStore hook, with a server snapshot of false and a change listener. It stays correct when the setting changes and doesn't cause hydration errors in Next.js.
Not by default. Motion's MotionConfig defaults to reducedMotion="never". Set it to "user" to turn off transform and layout animations for people who asked for less motion, while opacity and colour animations keep running.
motion-safe: applies a class only when the user has no motion preference. motion-reduce: applies it only when they asked for reduced motion. Put looping animations behind motion-safe: and use motion-reduce: to turn off transitions you added by default.
No. Remove or shorten movement like slides, zooms, parallax and spinning, and keep fades and colour changes that tell people something happened. The setting asks for less motion, not for no feedback.