# prefers-reduced-motion in React: a Hook, Tailwind and Motion

> Handle prefers-reduced-motion with CSS first: a media query or Tailwind's motion-safe and motion-reduce variants. Use a useSyncExternalStore hook only when JavaScript drives the animation, and wrap Motion in MotionConfig with reducedMotion set to user. Swap slides for fades instead of removing feedback.

- URL: https://qodesign.dev/blog/prefers-reduced-motion-react
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-20
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

`prefers-reduced-motion` is the media query that tells you a user asked their operating system for less motion. In React you can read it three ways: CSS (`@media (prefers-reduced-motion: reduce)` or Tailwind's `motion-reduce:` and `motion-safe:` variants), a small hook built on `useSyncExternalStore` for JavaScript-driven animation, and Motion's `<MotionConfig reducedMotion="user">` if you already use Motion. Use CSS wherever you can. Reach for the hook only when JavaScript runs the animation, like a canvas loop or a scripted sequence.

The goal is less motion, not no motion. A fade still tells someone a toast appeared. A 40 px slide, a parallax layer or a spinning logo is what causes trouble for people with vestibular disorders.

Below are the patterns I use in Next.js with Tailwind CSS v4, each run in headless Chromium with reduced motion on and off.

## What does prefers-reduced-motion actually detect?

It reports the user's system setting: Reduce Motion on iOS and macOS, animation effects turned off on Windows, and similar switches on Android and some Linux desktops. The query has two values, `no-preference` and `reduce` ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion)). It can change while the page is open, so code that reads it once on mount goes stale.

The WCAG criterion behind it is [2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html). It's level AAA, so most audits won't fail you for skipping it, but the users who need it notice right away.

## How do you handle reduced motion in plain CSS?

Swap movement for a fade inside the media query instead of deleting the animation:

```css
/* Movement by default */
.toast {
  animation: toast-in 300ms ease-out;
}
@keyframes toast-in {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: none; }
}

/* Reduced motion: keep the fade, drop the slide */
@media (prefers-reduced-motion: reduce) {
  .toast { animation-name: toast-fade; }
}
@keyframes toast-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
```

The blunt version you'll find in many resets sets every `animation-duration` and `transition-duration` to `0.01ms !important` under `reduce`. It works as a safety net, and the tiny duration still fires `animationend` and `transitionend`, so code waiting on those events keeps working. The cost is that a toast pops in with no fade at all, so I keep the reset for third-party CSS and write the fade by hand for my own components.

## How do motion-safe and motion-reduce work in Tailwind?

Tailwind compiles `motion-safe:` to `@media (prefers-reduced-motion: no-preference)` and `motion-reduce:` to `@media (prefers-reduced-motion: reduce)` ([Tailwind docs](https://tailwindcss.com/docs/hover-focus-and-other-states#prefers-reduced-motion)).

```tsx
<div className="size-10 rounded-full bg-lime-300 motion-safe:animate-spin" />

<button className="transition-transform duration-300 hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0">
  Save
</button>
```

Use `motion-safe:` for anything decorative that loops, so it only runs when the user has no preference. Use `motion-reduce:` to switch off a transition you added by default. In my test, the spinner's computed `animation-name` was `spin` with no preference and `none` with `reduce`, and the button's `transition-property` went from `transform, translate, scale, rotate` to `none`.

## A useReducedMotion hook that's safe in Next.js

When JavaScript drives the animation, read the query in JavaScript. Most hooks you'll find use `useState` plus `useEffect`, which renders once with the wrong value and then corrects itself. `useSyncExternalStore` is React's API for subscribing to a browser value like this one ([React docs](https://react.dev/reference/react/useSyncExternalStore)):

```ts
"use client";

import { useSyncExternalStore } from "react";

const QUERY = "(prefers-reduced-motion: reduce)";

function subscribe(onChange: () => void) {
  const mql = window.matchMedia(QUERY);
  mql.addEventListener("change", onChange);
  return () => mql.removeEventListener("change", onChange);
}

const getSnapshot = () => window.matchMedia(QUERY).matches;
const getServerSnapshot = () => false; // the server can't know; assume motion is fine

export function usePrefersReducedMotion() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
```

`getServerSnapshot` returns `false` because the server can't see the user's settings. During hydration React uses that server value, then re-renders with the real one, so you get no hydration mismatch error. The `change` listener keeps it live. I server-rendered a page with this hook, hydrated it in Chromium with reduced motion on, and got `reduced` after hydration with zero recoverable errors. Flipping the emulated setting while the page was open switched the value both ways without a reload.

If the same pattern is new to you, I covered `getServerSnapshot` and hydration in more depth in [the useSyncExternalStore post](/blog/usesyncexternalstore-getserversnapshot-nextjs).

## How does Motion handle reduced motion?

Wrap your tree in `MotionConfig` with `reducedMotion="user"`:

```tsx
"use client";

import { MotionConfig, motion } from "motion/react";

export function Toast({ open }: { open: boolean }) {
  return (
    <MotionConfig reducedMotion="user">
      {open && (
        <motion.div
          initial={{ opacity: 0, y: 40 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.3 }}
          className="rounded-xl bg-zinc-800 px-4 py-3"
        >
          Saved
        </motion.div>
      )}
    </MotionConfig>
  );
}
```

Per the [Motion docs](https://motion.dev/docs/react-motion-config), with reduced motion on, transform and layout animations are disabled while opacity and colour animations still run. That matched what I measured: halfway through a 2-second test animation with reduced motion on, the toast's opacity was 0.38 and its `transform` was `none`. With no preference, it was at 0.38 opacity and still 25 px below its final spot. The toast still fades in. It just doesn't travel.

The default is `"never"`, so without the wrapper Motion ignores the setting. Motion also logs a console message when it sees reduced motion turned on, pointing to its [troubleshooting page](https://motion.dev/troubleshooting/reduced-motion-disabled). That message is informational, not an error in your code.

For finer control inside one component, Motion exports a `useReducedMotion()` hook too.

## What about canvas and requestAnimationFrame loops?

CSS media queries can't reach a canvas. Use the hook and draw one still frame instead of starting the loop:

```tsx
"use client";

import { useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "./usePrefersReducedMotion";

export function Particles() {
  const ref = useRef<HTMLCanvasElement>(null);
  const reduce = usePrefersReducedMotion();

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    let frame = 0;
    let raf = 0;
    const draw = () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = "#c6f432";
      for (let i = 0; i < 40; i++) {
        const x = (i * 37 + frame) % canvas.width;
        ctx.fillRect(x, (i * 53) % canvas.height, 3, 3);
      }
    };
    if (reduce) {
      draw(); // one still frame, no loop
      return;
    }
    const tick = () => {
      frame++;
      draw();
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [reduce]);

  return <canvas ref={ref} width={320} height={120} />;
}
```

Because `reduce` is in the effect's dependency list, turning the setting on mid-session cancels the running loop, and turning it off starts it again. In my test the canvas pixels changed between two reads 300 ms apart with no preference, and stayed identical with `reduce`.

WebGL backgrounds, particle fields (like a [Three.js particle hero](/react/threejs-hero-section-nextjs)) and shader effects need this the most. They're the heaviest motion on most pages and the easiest to forget.

## How do you test reduced motion?

In Chrome DevTools, open the Command Menu, run "Show Rendering", and set "Emulate CSS media feature prefers-reduced-motion" to `reduce`. To keep it from regressing, add a Playwright test:

```ts
import { test, expect } from "@playwright/test";

test.use({ reducedMotion: "reduce" });

test("respects reduced motion", async ({ page }) => {
  await page.goto("/");
  const spinner = page.getByTestId("spinner");
  await expect(spinner).toHaveCSS("animation-name", "none");
});
```

I used the same emulation, driven from Playwright, for every number in this post.

## A checklist before you ship an animated component

1. Loops and decorative motion use `motion-safe:` or stop under `reduce`.
2. Slides, zooms and parallax become fades under `reduce`.
3. Feedback that carries meaning stays visible: a fade, a colour change, a check mark.
4. JavaScript animation reads the live hook, not a value captured on mount.
5. Canvas and WebGL draw a still frame.
6. There's a Playwright test with `reducedMotion: "reduce"`.

I run this checklist on the components I make at qodesign. A good one to practise on is the [Day / Night toggle](/components/day-night-toggle): its sun-to-stars transition is exactly the kind of movement worth swapping for a crossfade. You can grab a component with full source from [/free](/free), and the [hold-to-delete button](/components/hold-to-delete) is a good test of rule 3, because its press progress is feedback you shouldn't remove.

## FAQ

### How do I check prefers-reduced-motion in React?

Use `window.matchMedia("(prefers-reduced-motion: reduce)")` inside a `useSyncExternalStore` hook, with a server snapshot of `false` and a `change` listener. It stays correct when the setting changes and doesn't cause hydration errors in Next.js.

### Does Framer Motion respect prefers-reduced-motion automatically?

Not by default. Motion's `MotionConfig` defaults to `reducedMotion="never"`. Set it to `"user"` to turn off transform and layout animations for people who asked for less motion, while opacity and colour animations keep running.

### What is the difference between motion-safe and motion-reduce in Tailwind?

`motion-safe:` applies a class only when the user has no motion preference. `motion-reduce:` applies it only when they asked for reduced motion. Put looping animations behind `motion-safe:` and use `motion-reduce:` to turn off transitions you added by default.

### Should I remove all animations when prefers-reduced-motion is set?

No. Remove or shorten movement like slides, zooms, parallax and spinning, and keep fades and colour changes that tell people something happened. The setting asks for less motion, not for no feedback.
