A practical guide to prefers-reduced-motion in React and Next.js
How to properly honor prefers-reduced-motion in React and Next.js with Framer Motion, CSS, and IntersectionObserver-based animations — including the patterns that actually pass accessibility audits.
Animation makes interfaces feel alive. It also makes some people physically ill. About 3% of adults have vestibular disorders that can be triggered by parallax, zoom, or bouncy transitions — and every major operating system now exposes a user preference to reduce motion. If you ship animated React or Next.js apps, honoring prefers-reduced-motion is not optional; it is a WCAG success criterion and a baseline expectation.
This guide is the practical version. No theory, just the patterns we use on every production site to pass accessibility audits.
What prefers-reduced-motion is
prefers-reduced-motion is a CSS media query that reflects a user's OS-level preference. macOS has Reduce motion under Accessibility → Display. Windows has Show animations in Windows under Ease of Access. iOS and Android have similar toggles. When the user flips that switch, you are expected to reduce or disable non-essential animation everywhere.
Fix it in CSS first
The fastest win is a single media query in your global stylesheet. This catches every CSS animation and transition on the site — including third-party components that you might not control.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}The 0.001ms value keeps your transitions technically valid (so state updates still fire) without the user perceiving motion. setting it to 0ms can break components that rely on transitionend events.
Framer Motion: use MotionConfig
If you use Framer Motion, wrap your app in MotionConfig with reducedMotion="user". Every motion component inside will automatically check the user preference and disable transforms when it is set.
// app/layout.tsx or equivalent
"use client";
import { MotionConfig } from "framer-motion";
export function MotionProvider({ children }: { children: React.ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}This is one line and it fixes 90% of Framer Motion a11y audits. The components still animate for users without the preference; they become static for users with it.
Custom animations and IntersectionObserver
For animations you run yourself (timers, scroll-linked effects, canvas), you need to check the preference explicitly. A tiny React hook handles it and updates live if the user changes the OS setting while your app is running:
import { useEffect, useState } from "react";
export function usePrefersReducedMotion(): boolean {
const [reduce, setReduce] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduce(mq.matches);
const handler = (e: MediaQueryListEvent) => setReduce(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reduce;
}Use it in any component that does its own animation:
"use client";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
export function DecryptedText({ text }: { text: string }) {
const reduce = usePrefersReducedMotion();
const [display, setDisplay] = useState(reduce ? text : "");
useEffect(() => {
if (reduce) {
setDisplay(text);
return;
}
// run the animation
}, [reduce, text]);
return <span>{display}</span>;
}SSR and hydration gotchas
prefers-reduced-motion only exists in the browser. If you try to read it during server rendering, you will get the wrong answer or a hydration mismatch. Two rules:
- Never read the media query during render on the server. Wrap calls in a useEffect or default to 'no preference' for the first paint.
- For critical animations, prefer CSS-driven behavior over JS-driven. CSS evaluates at paint time in the browser and never hits the hydration path.
Scroll-linked effects
Parallax and scroll-triggered effects are the worst offenders for vestibular disorders. If you use Framer Motion's useScroll, useTransform, or scroll-linked libraries, you must disable them under the preference:
const { scrollYProgress } = useScroll();
const rawY = useTransform(scrollYProgress, [0, 1], [0, -200]);
const reduce = usePrefersReducedMotion();
const y = reduce ? 0 : rawY;
return <motion.div style={{ y }} />;How to test it
Three ways, in increasing order of rigor:
- Chrome DevTools → Rendering panel → Emulate CSS media feature prefers-reduced-motion: reduce. Reload the page and verify nothing animates.
- Toggle it at the OS level on your primary dev machine and reload. This catches cases where you forgot to listen for changes.
- Run Lighthouse and Axe. Both flag components that animate despite the preference.
The short version
Add the global CSS media query. Wrap Framer Motion in MotionConfig. Write a tiny hook for everything else. Test in DevTools before every release. That is the whole playbook — and it turns motion accessibility from a last-minute audit scramble into a one-line default for every project.
Frequently asked questions
Does prefers-reduced-motion disable all animation, or just some?
It is a hint, not a ban. You are expected to reduce or remove non-essential motion — parallax, zoom, bouncy transitions, auto-playing videos. Small functional feedback like a button press or a spinner is generally fine to keep, as long as it is brief and not disorienting.
Is prefers-reduced-motion required by WCAG?
WCAG 2.3.3 (Level AAA) explicitly covers interaction-triggered animation; 2.2.2 (Level A) covers auto-playing motion. Most accessibility audits require at least AA compliance, and honoring prefers-reduced-motion is considered a baseline expectation even when not strictly required.
Will Framer Motion honor prefers-reduced-motion automatically?
Only if you opt in by wrapping your app in <MotionConfig reducedMotion="user">. Without it, Framer Motion ignores the preference by default. This is the single most common mistake we see in audits.
How do I test my site with reduced motion without changing my OS settings?
Open Chrome DevTools, switch to the Rendering panel, and under Emulate CSS media feature, choose prefers-reduced-motion: reduce. Reload the page — your site should now behave as it would for a user with the preference enabled.