Respecting prefers-reduced-motion in React
Read prefers-reduced-motion in React with Motion or CSS, decide what reduced should mean, pause canvas and WebGL loops, and test the setting in Chrome DevTools.
Published by Mekai Games
prefers-reduced-motion is a CSS media feature that tells your page the user asked their operating system for less motion. Animated components should listen to it. This guide covers how to read it in React, what a reduced version of an animation should look like, how to handle canvas and WebGL loops, and how to test it, with patterns taken from Mekai UI components.
What the setting is and who turns it on
The setting lives in the operating system, and browsers expose it to CSS and JavaScript as prefers-reduced-motion: reduce. Users find it here:
- macOS: System Settings, Accessibility, Display, Reduce motion.
- iOS and iPadOS: Settings, Accessibility, Motion, Reduce Motion.
- Windows 11: Settings, Accessibility, Visual effects, Animation effects turned off.
- Android: Settings, Accessibility, Remove animations, under Color and motion on recent versions.
People with vestibular disorders turn it on because large movement, parallax and zooming can cause dizziness, nausea or migraines. Others turn it on because motion distracts them, or because they prefer a calmer interface. WCAG 2.3.3, Animation from Interactions, asks that motion triggered by interaction can be turned off unless it is essential, and 2.2.2, Pause, Stop, Hide, requires a way to pause content that moves on its own for more than five seconds.
Reading the setting in React
With Motion, call useReducedMotion from motion/react. It returns true when the user asked for reduced motion, false when not, and null during server rendering. Mekai components read it once at the top and call it reduce. Some normalize the null with Boolean(useReducedMotion()) or useReducedMotion() ?? false. A typical use keeps the fade and drops the movement:
"use client";
import { motion, useReducedMotion } from "motion/react";
export function SavedNotice({ message }: { message: string }) {
const reduce = useReducedMotion() ?? false;
return (
<motion.p
role="status"
initial={{ opacity: 0, y: reduce ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.4, ease: "easeOut" }}
>
{message}
</motion.p>
);
}To apply one rule to a whole tree, wrap it in <MotionConfig reducedMotion="user">. Motion then switches off transform animations, such as movement, scale and rotation, for users who asked for less motion, while opacity and color changes still run. The hook is still the tool for anything MotionConfig cannot see: timers, typing effects and canvas loops.
Without Motion, read the media query with matchMedia and listen for its change event. useSyncExternalStore keeps the value current and gives the server render a fallback:
import { useSyncExternalStore } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function subscribe(onChange: () => void) {
const media = window.matchMedia(QUERY);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
export function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false, // the server cannot know, so assume no preference
);
}For CSS transitions and keyframes, use @media (prefers-reduced-motion: reduce) directly. Tailwind CSS 4 has motion-safe: and motion-reduce: variants that compile to that query. Here the dot still says live without the pulse:
export function LiveBadge() {
return (
<span className="inline-flex items-center gap-2">
<span className="size-2 rounded-full bg-red-500 motion-safe:animate-pulse" />
Live
</span>
);
}What reduced should mean
Reduced motion does not mean no feedback. The goal is to remove movement the user did not ask for or that carries no information, and to keep every change of state visible.
- Replace movement with a fade or an instant change. Sliding, zooming, parallax and spinning are the usual triggers; opacity changes are usually fine.
- Shorten what has to stay. A transition that tells the user something changed can run in 150 ms instead of 600.
- Stop loops and ambient motion: pulsing glows, drifting backgrounds, carousels that advance on their own.
- Never hide content. If an element starts at
opacity: 0and only the animation brings it in, skipping the animation leaves it invisible. Render the final state instead. - Keep the outcome. A reward, a score or an error still has to appear; only the way it arrives changes.
Health Bar shows the split. On damage it shakes the bar, but only when motion is allowed:
useEffect(() => {
if (!change || change.kind !== "damage" || reduce || !scope.current) return;
animate(scope.current, { x: [0, -5, 5, -3, 3, 0] }, { duration: 0.32, ease: "easeOut" });
}, [change, reduce, animate, scope]);The bar width still drops, because the new health value is the information. The floating damage number keeps its fade and loses its rise with y: reduce ? 0 : -22, the heal glow fades in 0.2 seconds instead of 0.9, and the white hit flash and the low-health pulse are skipped.
Dialogue Box, a Pro component, applies the never-hide rule to text. With reduced motion each line appears whole instead of typing out:
const typed = reduce ? text.length : Math.min(shown, text.length);The box also mounts with initial={reduce ? false : { opacity: 0, y: 12 }}, so it renders in its final state, and its exit keeps the fade without the slide. Typewriter does the same for headlines: it shows one full phrase instead of typing, and all phrases sit in a screen-reader-only span either way. Spin Wheel shortens instead of removing: one turn in at most a second, so the result still has a visible cause.
Canvas and WebGL loops
Motion cannot see inside a <canvas>. A background that draws with requestAnimationFrame keeps running until your own code stops it. You have two options: draw one still frame and stop, or skip the canvas and show a static fallback such as a gradient. A still frame keeps the look of the design, so the page does not change character for these users.
Starfield draws a still frame. Its loop moves stars only when motion is allowed, and schedules the next frame only when motion is allowed and the canvas is on screen:
for (const s of stars) {
if (!reduce) s.z -= velocity * dt;
// project the star and add it to its depth path
}
if (!reduce && visible) frame = requestAnimationFrame(draw);
else frame = 0;On resize it calls draw(0) when reduce is true, so the still frame is redrawn at the new size instead of stretching. The warp streaks also shrink to points, since a long streak reads as speed even when nothing moves. Silk uses the same pattern for WebGL: it renders one frame with render(0) and only adds its pointermove listener when motion is allowed, so the sheen does not follow the cursor. Both also pause when scrolled out of view through an IntersectionObserver, which saves battery for everyone.
Health Bar
Game UITypewriter
Text AnimationsDialogue Box
Game UISpin Wheel
Game UIStarfield
BackgroundsSilk
BackgroundsTesting it
- Turn the OS setting on and reload. Motion's hook reads the value at mount, so a reload gives you a clean result.
- In Chrome DevTools, open the Command Menu with Cmd+Shift+P on macOS or Ctrl+Shift+P elsewhere, run Show Rendering, and set Emulate CSS media feature prefers-reduced-motion to reduce. Reload after switching it.
- In end-to-end tests, Playwright emulates it with
page.emulateMedia({ reducedMotion: "reduce" }).
Then walk through the page and check four things: nothing slides, zooms or spins on load; every piece of text is readable without waiting; state changes such as a score, a health value or a prize still show; and nothing keeps moving in the background.
Mekai components end up as files in your project, added with the shadcn CLI or copied from the Pro repository as described in Installation. If your product needs a different reduced version, such as no fade at all, change the reduce branch in the file.
Components in this guide
More guides
- Animated hero backgrounds in Next.jsAdd an animated background behind a Next.js App Router hero: choose DOM, canvas or WebGL, stack content on top, keep text readable and spare the battery.
- Text animations in React: which one to usePick a React text animation by its job: entrance, scroll reveal, CTA highlight, stats or decoding state, with screen reader text and reduced motion built in.
- Gamification UI in React: XP, streaks, leaderboardsBuild XP bars, streaks, daily rewards, achievements, leaderboards and loot reveals in React, and learn when each one helps users and when it turns into noise.
- Install React components from a shadcn registryHow the shadcn CLI installs components from a custom registry URL, what lands in your project, and how to add, update and edit Mekai UI components.