core accessibility principles for modern frameworks
Respecting prefers-reduced-motion in React and CSS
A single application animates in two different runtimes. CSS transitions and keyframes are declarative and the browser owns them; Framer Motion springs, GSAP timelines, and hand-rolled requestAnimationFrame loops run in JavaScript and the browser knows nothing about your intent. prefers-reduced-motion reaches the first layer for free through a media query, but the second layer needs an explicit read in code. This guide gives you both halves—a CSS-first pattern and a useReducedMotion() hook—so every animation in a framework app respects the user's choice. It is the implementation companion to Reduced Motion & Animation Accessibility.
Prerequisites
This guide assumes you can already render a component and write a stylesheet in a React (or Next.js) project. You should be familiar with useEffect and useState, and—if you use one—the basics of your animation library. No external dependency is required for the core pattern; window.matchMedia is a built-in browser API. Framer Motion examples are optional and clearly marked.
One conceptual prerequisite: understand the two states of the media feature. (prefers-reduced-motion: reduce) matches when the user has asked their OS to minimize motion; (prefers-reduced-motion: no-preference) matches when they have not. There is no third state, and the absence of a preference is not the same as a preference for motion—it simply means you have not been told to reduce.
The CSS-First Pattern
CSS should carry as much of the work as possible because the browser applies it before any JavaScript runs, with zero hydration risk. The defensive instinct is to define an animation and strip it under reduce:
/* Reactive: easy to forget the override on the next animation you add */
.panel {
transition: transform 220ms ease;
}
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}
The safer inversion is motion-safe: put the animation inside a no-preference query so it only exists for users who have not asked to reduce. Forgetting the query then fails safe—you ship a static element, never an unwanted one.
/* Motion-safe: animation is opt-in, the static state is the default */
.panel {
/* resting, static appearance — no transition declared here */
}
@media (prefers-reduced-motion: no-preference) {
.panel {
transition: transform 220ms ease, opacity 220ms ease;
}
}
If you use Tailwind, the motion-safe: and motion-reduce: variants compile to exactly these queries—prefer motion-safe: so the un-prefixed utility stays static. For a large existing codebase that cannot be refactored rule by rule, add a conservative global guard as a floor. Use a near-zero duration rather than none so JavaScript waiting on transitionend or animationend still fires its callback:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; /* break infinite loops */
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Treat the guard as a safety net while you migrate hot paths to the motion-safe pattern, not as the finished solution—it disables useful feedback along with the harmful motion.
A useReducedMotion() Hook
CSS cannot reach JavaScript-driven animation. To gate a Framer Motion spring, a GSAP timeline, or a requestAnimationFrame loop, read the same media feature in code through window.matchMedia and subscribe to changes.
'use client';
import { useEffect, useState } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
/**
* Returns true when the user prefers reduced motion.
* Defaults to `true` so SSR and first paint assume the safe, low-motion
* state — a vestibular user never sees a large animation before hydration.
*/
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(true);
useEffect(() => {
const mql = window.matchMedia(QUERY); // window only exists client-side
setReduced(mql.matches); // sync to the real value after mount
const onChange = (event: MediaQueryListEvent) => setReduced(event.matches);
mql.addEventListener('change', onChange); // respect a mid-session OS toggle
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
Two design choices carry the accessibility weight. The change listener means a user who flips "Reduce motion" in their OS while your app is open is honored immediately, with no reload—matchMedia().matches read once would silently ignore them. The default of true is the fail-safe direction discussed under the SSR caveat below.
This hook belongs alongside your other accessibility utilities; see React Hooks for Accessibility for the broader pattern of wrapping browser accessibility APIs in reusable hooks.
Gating JS and Framer Motion Animations
With the hook in hand, branch your animation on its value. The pattern is one component with two presentation paths—reduced motion gets a crossfade, not a removed transition, so feedback survives.
'use client';
import { motion } from 'framer-motion';
import { useReducedMotion } from './useReducedMotion';
export function Drawer({ children }: { children: React.ReactNode }) {
const reduced = useReducedMotion();
// Full variant slides in from the side; reduced variant only fades.
const variants = reduced
? { closed: { opacity: 0 }, open: { opacity: 1 } }
: { closed: { opacity: 0, x: -320 }, open: { opacity: 1, x: 0 } };
return (
<motion.aside
initial="closed"
animate="open"
exit="closed"
variants={variants}
transition={{ duration: reduced ? 0.12 : 0.3 }}
>
{children}
</motion.aside>
);
}
Framer Motion also offers a library-native route that avoids per-component branching. Its own useReducedMotion() reads the same media feature, and wrapping your tree in <MotionConfig reducedMotion="user"> makes every nested motion element automatically drop transform and layout animations while keeping opacity. If you already depend on Framer Motion, prefer MotionConfig for app-wide coverage and reserve a custom hook for non-Framer code like GSAP or raw requestAnimationFrame.
For an imperative animation loop, the gate is just an early branch:
const reduced = useReducedMotion();
useEffect(() => {
if (reduced) {
element.style.transform = 'none'; // jump straight to the end state
return;
}
// ...start the requestAnimationFrame tween only when motion is welcome
}, [reduced]);
The SSR Caveat
Server-side rendering has no window, so window.matchMedia cannot run during the server pass or it throws. Three rules keep the hook SSR-safe.
First, only touch window inside useEffect (or a typeof window !== 'undefined' guard), never in the render body or a useState initializer that runs on the server. Effects do not execute during SSR, so the read is deferred to the client where window exists.
Second, default to true. The server has to render some value, and it cannot know the user's preference. Defaulting to reduced motion means the worst-case first paint is a missing animation—harmless—rather than a large spatial transition firing before hydration corrects it, which is exactly what a vestibular user disabled motion to avoid. Fail safe toward less motion.
Third, expect a brief mismatch and design for it. The server renders the reduced state, then the effect runs on the client and may switch to full motion for a no-preference user. Because the static state is your baseline, this resolves as "animation becomes available" rather than "animation is yanked away"—a non-disruptive direction. Avoid reading the preference during render to influence server markup; keep it in the effect so hydration stays stable.
How to Verify
Verification needs three layers: real OS behavior, fast emulation, and an automated regression guard.
1. OS setting (the source of truth). Enable the system toggle and reload:
- macOS: System Settings → Accessibility → Display → Reduce motion
- Windows: Settings → Accessibility → Visual effects → Animation effects off
- iOS: Settings → Accessibility → Motion → Reduce Motion
- GNOME/Linux: Settings → Accessibility → Reduce Animation
Confirm CSS transitions collapse and Framer Motion components fall back to their crossfade variant. Then toggle the setting with the app still open—the change listener should switch behavior without a reload.
2. DevTools emulation (fast iteration). In Chrome, open DevTools → Rendering panel → Emulate CSS media feature prefers-reduced-motion and set it to reduce. Firefox exposes ui.prefersReducedMotion in about:config. Emulation drives the media query but does not always fire matchMedia change events, so use it for CSS checks and the OS toggle for the listener.
3. An automated test. Mock matchMedia in jsdom (it is undefined there by default) and assert the hook returns the mocked value:
import { renderHook } from '@testing-library/react';
import { useReducedMotion } from './useReducedMotion';
function mockMatchMedia(matches: boolean) {
window.matchMedia = (query: string) => ({
matches,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
onchange: null,
dispatchEvent: () => false,
} as MediaQueryList);
}
test('reports reduced motion when the OS prefers it', () => {
mockMatchMedia(true);
const { result } = renderHook(() => useReducedMotion());
expect(result.current).toBe(true);
});
Common a11y Mistakes
- Reading
matchMediaonce, no listener: Ignores a mid-session OS toggle. Always subscribe to thechangeevent. - Defaulting the hook to
false: Ships motion on first paint before hydration. Default totrue. - Calling
window.matchMediain the render body: Throws during SSR. Confine it touseEffect. - Using
transition: nonein the global guard: Breaks JavaScript that waits ontransitionend. Use a0.01msduration instead. - Gating only CSS: A
Framer Motionspring or GSAP timeline keeps running because CSS media queries do not reach it. Gate it on the hook orMotionConfig. - Reactive
reduceoverrides only: Forgetting one override ships unwanted motion. Prefer the motion-safeno-preferencedefault.
Frequently Asked Questions
Do I need both the CSS media query and the React hook?
Yes. The media query governs declarative CSS transitions and keyframes with no hydration risk, while the hook is the only way to gate JavaScript-driven animation like Framer Motion, react-spring, GSAP, or requestAnimationFrame loops. They cover different runtimes.
Why does the hook default to true (reduced) instead of false?
The server has no window and cannot know the preference, so it must render a default. Defaulting to reduced means first paint is static—at worst a missing flourish for a no-preference user, which resolves harmlessly after hydration—rather than an unwanted large animation firing for someone who disabled motion.
Should I use Framer Motion's built-in support or my own hook?
If you already depend on Framer Motion, use its useReducedMotion() and <MotionConfig reducedMotion="user"> for automatic app-wide coverage. Use a custom hook for non-Framer animation—GSAP, raw requestAnimationFrame, or conditional logic that the library cannot see.
Will DevTools emulation fully test my hook?
It reliably drives the CSS media query, but emulation does not always dispatch matchMedia change events, so it cannot exercise the live-toggle path. Verify the change listener with the real OS setting toggled while the app is open.