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 matchMedia once, no listener: Ignores a mid-session OS toggle. Always subscribe to the change event.
  • Defaulting the hook to false: Ships motion on first paint before hydration. Default to true.
  • Calling window.matchMedia in the render body: Throws during SSR. Confine it to useEffect.
  • Using transition: none in the global guard: Breaks JavaScript that waits on transitionend. Use a 0.01ms duration instead.
  • Gating only CSS: A Framer Motion spring or GSAP timeline keeps running because CSS media queries do not reach it. Gate it on the hook or MotionConfig.
  • Reactive reduce overrides only: Forgetting one override ships unwanted motion. Prefer the motion-safe no-preference default.

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.