· 3 min read
prefers-reduced-motion in React, and the SSR case everyone skips
The usual hook works fine until you server-render it, then it flashes the animation it was supposed to suppress. Why that happens, and the version that doesn't.
Most articles about prefers-reduced-motion in React give you this hook:
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(query.matches);
const onChange = (e) => setReduced(e.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}It's correct, and in a client-rendered app it's fine. Server-render it and it does the one thing it exists to prevent.
What goes wrong
window.matchMedia doesn't exist on the server, so useState(false) is the answer the server commits to. The HTML ships saying "no reduced motion preference". The browser hydrates with that same false, because hydration has to match what the server sent or React complains. Only after the first effect runs does the value flip to true.
For a user who asked their operating system to reduce motion, the sequence is: page arrives, animation starts, animation gets cancelled. They see the thing they asked not to see — briefly, which for a vestibular trigger is not meaningfully better than seeing it for a long time.
Worse, it's invisible in development. You're probably not browsing with reduced motion on, so the flash never happens to you, and it never shows up in a test that doesn't set the preference.
The fix
useSyncExternalStore exists for exactly this: external state that React didn't create, read consistently across server and client.
import { useSyncExternalStore } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function subscribe(callback) {
const query = window.matchMedia(QUERY);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
export function useReducedMotion() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => true
);
}Three arguments, and the third is the whole point. It's the server snapshot — what this hook returns when there's no browser — and returning true means the server renders the reduced version.
That's a deliberate choice and it's worth being explicit about. We're defaulting to no animation for anyone whose preference we can't read yet, and letting the client correct us upward if they didn't ask for reduced motion. Getting that wrong in the other direction shows motion to someone who asked for none; getting it wrong in this direction shows a still frame for a few milliseconds to someone who'd have been fine either way.
The part the hook doesn't solve
A reduced-motion hook tells you whether. It doesn't tell you what instead, and that's the harder half.
The common answer is to skip the animation — render the end state, no transition. It works, and it's a worse experience than it needs to be. Motion carries meaning: a toast that appears instantly is ambiguous about whether it's new, and a modal that snaps into place loses the connection to the thing that opened it.
The preference is called reduce, not remove. What most people with it turned on actually want is no large movement across the screen — not a completely static page. A fade with no travel usually satisfies it, keeps the meaning, and takes about a line.
const reduced = useReducedMotion();
<motion.div
initial={{ opacity: 0, y: reduced ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={reduced ? { duration: 0.01 } : { type: "spring", stiffness: 260, damping: 26 }}
/>If you're using Motion, MotionConfig with reducedMotion="user" does the travel-stripping automatically for transform properties, which covers most of this without per-component work. It doesn't help outside Motion, and it doesn't decide what a reduced version should feel like.
How verve handles it
An export can carry its reduced variant, generated from the animation you already made rather than written by hand. Travel is removed, opacity survives with its springs stripped, and what's left is capped at 150ms. The CSS export appends a prefers-reduced-motion block on the same selector; the Motion export reads the preference with a hook, because that's the idiomatic answer there.
Deriving it is the point. Writing the reduced variant by hand means writing the animation twice, and the second copy silently rots the first time anyone tunes the first.
There's also a preview lens in the editor, so you can watch the reduced version before shipping it. That matters more than it sounds: the reduced variant is the one nobody on the team ever sees, which is why it's usually the one that's broken.