# Prefers Reduced Motion React: Calm Motion, No Mismatch

> Prefers reduced motion React guide: respect the OS setting in Next.js without a hydration mismatch, and replace springs with short fades that keep feedback.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind)
- Reading time: 8 min
- Canonical: https://wingo-ui.com/blog/prefers-reduced-motion-react

## TL;DR

To respect prefers-reduced-motion in a React app that renders on the server, decide as much as you can in CSS (Tailwind's motion-safe: and motion-reduce: variants), set MotionConfig reducedMotion="user" once, and read the preference in components through a useSyncExternalStore hook whose server snapshot is false, because Motion's useReducedMotion returns null on the server and a different value in the browser. Under reduced motion, replace movement with a short opacity fade instead of turning animation off, so every state change stays visible.

Most prefers reduced motion React answers are a one-line hook and a CSS snippet that sets every animation to `0.01ms`. Then the Next.js console reports a hydration mismatch the first time someone with Reduce Motion turned on opens the app, and the dialog that used to scale in now pops into view with no feedback at all. This tutorial covers what the setting asks for, where to decide it (CSS, a Motion config, or a hook), why Motion's own hook trips hydration, and what to put in place of a spring so the interface stays readable.

Facts below were checked on October 13, 2026 against the source of Motion 12.43, React 19.3 and Tailwind CSS 4.3.3.

## What does prefers-reduced-motion ask for?

Less movement. The media feature turns true when someone enables Reduce motion on macOS or iOS, Animation effects off on Windows 11, Remove animations on Android, or the equivalent on GNOME and KDE; [MDN lists every path](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). People turn it on for different reasons: vestibular disorders, migraines, distraction, or a phone that feels faster without transitions.

The [WCAG understanding document for 2.3.3](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) names the risk: non-essential motion can cause "dizziness, nausea and headaches", with parallax and scroll-triggered movement as its examples. That criterion is level AAA. The level A one that applies to every site is [2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html), for motion that starts on its own and runs longer than five seconds: carousels, marquees, looping backgrounds.

The setting does not ask for a frozen interface. A useful test for accessible animation: keep the information an animation carries (something opened, something saved, this item moved there) and drop the travel.

## How do you handle prefers-reduced-motion in Tailwind?

Use the `motion-safe:` and `motion-reduce:` variants, and start there, because CSS has no hydration problem: the media query applies on the first paint, before any JavaScript runs. This is what Tailwind 4.3.3 compiles them to:

```css
@media (prefers-reduced-motion: no-preference) {
  @media (hover: hover) {
    .motion-safe\:hover\:-translate-y-0\.5:hover {
      --tw-translate-y: calc(var(--spacing) * -0.5);
      translate: var(--tw-translate-x) var(--tw-translate-y);
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .motion-reduce\:transition-none {
    transition-property: none;
  }
}
```

`motion-safe:` adds motion only for people who have not asked for less, so the still version is the baseline and movement is the extra. `motion-reduce:` removes motion you added unconditionally. Our [Card](https://wingo-ui.com/components/card) lifts on hover with `motion-safe:hover:-translate-y-0.5` and keeps its shadow change for everyone, so the hover still reads under reduced motion, just without the movement.

That is the whole prefers-reduced-motion Tailwind story for CSS transitions, keyframe animations and spinners. Use it for anything that does not need JavaScript to decide.

## Why does useReducedMotion cause a hydration mismatch?

Because it returns a different value on the server. In Motion 12.43, `prefersReducedMotion.current` in motion-dom starts as `null` (its comment says it "Returns `null` server-side"), and `useReducedMotion()` copies it into `useState` during the first render. On the server that is `null`. In the browser the hook reads the media query just before `useState`, so hydration gets `true` or `false`. The hook keeps the value in state and never updates it; a TODO in the source asks whether people miss live updates.

We rendered each approach with React's `renderToString`, hydrated it in Chromium through Playwright with and without `reducedMotion: "reduce"`, and read the console:

| Approach | Reduced motion on | Reduced motion off | Follows a settings change |
| --- | --- | --- | --- |
| Motion `useReducedMotion()` choosing `initial` | "A tree hydrated but some attributes ... didn't match" | Clean as a boolean; rendered into an attribute it is `null` vs `false`, a mismatch on every visit | No, read once into state |
| `useSyncExternalStore` hook, server snapshot `false` | Clean; an entrance that plays during hydration runs in full once | Clean | Yes |
| `<MotionConfig reducedMotion="user">` | Clean; transforms jump to the end, opacity still fades | Clean | For elements mounted after the change |

That is the useReducedMotion hydration problem in one row. The usual pattern looks harmless:

```tsx
// wrong in a server-rendered app: null on the server, true in the browser
const reduce = useReducedMotion();
return <motion.div initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} />;
```

With the setting on, the server renders `opacity: 0; transform: translateY(8px)` into the style attribute, the browser expects the end state, and React logs the mismatch in development. React does not patch the attribute; Motion rewrites the styles right after mount, so visitors see nothing wrong, but the console reports an error on every load and buries the mismatches that matter.

## Is MotionConfig reducedMotion="user" enough?

It is the right global default, and it is hydration-safe. Motion reads the preference when each motion element mounts in the browser, so nothing about it is in the markup. The default is `"never"`, which means Motion ignores the setting unless you opt in. Put it once in a client provider:

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { MotionConfig } from "motion/react";

export function Providers({ children }: { children: ReactNode }) {
  return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}
```

The [Motion accessibility docs](https://motion.dev/docs/react-accessibility) describe what it does: transform and layout animations are disabled "while preserving the animation of other values" such as opacity. In the 12.43 source, every positional value (`x`, `y`, `scale`, `rotate`, plus `width`, `height`, `top` and `left`) gets `{ type: false }` and jumps to its target, while every other value keeps its transition. In development Motion also logs "You have Reduced Motion enabled on your device" once; that is expected, not a bug.

That covers most Motion React reduced motion needs, with two gaps. It cannot change what you render (a static image instead of an autoplaying video, a list without a stagger), and a scale or a slide that jumps to its end can look like a glitch next to a fade. For those, read the preference in the component.

## How do you write a hydration-safe usePrefersReducedMotion hook?

Read the media query through `useSyncExternalStore` and give it a server snapshot, the same way an SSR-safe media query hook works:

```ts
// hooks/use-prefers-reduced-motion.ts
import { useSyncExternalStore } from "react";

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

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

// false on the server and during hydration, then the real preference; updates when the setting changes
export function usePrefersReducedMotion() {
  return useSyncExternalStore(subscribe, () => window.matchMedia(QUERY).matches, () => false);
}
```

React renders the server snapshot during hydration, so both sides agree, then re-renders with the real value right after. Components that mount later, such as a dialog that opens on a click, read the real value on their first render. This is why our `lib/motion` ships its own `usePrefersReducedMotion` on top of the [useMediaQuery](https://wingo-ui.com/components/use-media-query) hook instead of re-exporting Motion's. The [React use mobile hook](https://wingo-ui.com/blog/react-use-mobile-hook-ssr) post explains the `useSyncExternalStore` mechanics in detail.

The trade-off is in the table: something that animates while the page hydrates sees `false` and plays its full entrance once. Keep page-load animations in CSS with `motion-safe:`, or rely on `MotionConfig` for them, and use the hook for what opens after a click.

## What should replace springs under reduced motion?

A short fade of the same change. Removing the animation entirely loses information: a toast that appears with no transition in a corner you are not looking at is easy to miss, and a dialog that pops in can feel like a page glitch. Our rule for every component:

- **Drop travel.** No translate, scale, rotate or skew, no parallax, no stagger.
- **Keep opacity, shorter.** A 150ms linear fade, which reads as a change of state without movement.
- **Fade things that only moved.** A bottom sheet has no opacity animation of its own, so under reduced motion it gets one.
- **Stop what loops or starts on its own.** Autoplay, marquees, ambient backgrounds.

In code that is one function. `reducePreset` in [Motion presets](https://wingo-ui.com/components/motion) removes transform keys from a preset's `initial`, `animate` and `exit`, keeps opacity (adding it when the preset had none) and swaps the transition for 150ms linear. `useMotionPreset` applies it from the hook:

```tsx
// components/saved-banner.tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import { useMotionPreset } from "@/lib/motion";

export function SavedBanner({ show }: { show: boolean }) {
  // springs in with a 4px rise normally, a 150ms fade under reduced motion
  const fade = useMotionPreset("fade");

  return (
    <AnimatePresence>
      {show && (
        <motion.p {...fade} className="rounded-2xl bg-success-soft px-4 py-3 text-sm text-success-soft-foreground">
          Changes saved
        </motion.p>
      )}
    </AnimatePresence>
  );
}
```

Turn on reduced motion in your OS, or in DevTools under Rendering, then replay the demo. The spring balls stop running and the list rows fade in place instead of rising:

> Live demo (Motion presets): Replay the list with each spring, then turn on reduced motion in your system settings: the balls stop and the same rows fade in place with no travel. Try it at [Motion presets](https://wingo-ui.com/components/motion) and install it with `npx wingo-ui@latest add motion`.

## How do the Toast and Dialog handle reduced motion?

They follow the same rule, with a few decisions the presets cannot make on their own. The free [Toast](https://wingo-ui.com/components/toast) normally rises from the bottom edge, stacks the older toasts behind the newest at a smaller scale, and slides toward the edge when dismissed. Under reduced motion a new toast fades in at its final slot, a dismissed one fades in place, and the stack keeps its offsets without the scale step. The toast is still a live region, so screen readers announce it either way:

> Live demo (Toast): Fire a few toasts, then try again with reduced motion turned on: they fade in at their slot instead of rising from the edge. Try it at [Toast](https://wingo-ui.com/components/toast) and install it with `npx wingo-ui@latest add toast`.

```tsx
// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { MotionConfig } from "motion/react";
import { Toaster } from "@/components/ui/toast";

export function Providers({ children }: { children: ReactNode }) {
  return (
    <MotionConfig reducedMotion="user">
      {children}
      <Toaster />
    </MotionConfig>
  );
}
```

Call `toast.success("Invoice sent")` from any client component and it picks the right motion by itself.

The [Dialog](https://wingo-ui.com/components/dialog) scales up from 96% on a smooth spring, and a dialog opened from inside it pushes the first one back and up. Under reduced motion the backdrop and the card only fade, and the stacked dialog does not step back. The one animation we drop without a replacement is the small 2% bump a non-dismissible dialog gives when you click outside it; the dialog still stays open and focus stays inside it, which is the answer that matters.

> Live demo (Dialog): Open the dialog with and without reduced motion: the card scales up on a spring, or fades in place. Try it at [Dialog](https://wingo-ui.com/components/dialog) and install it with `npx wingo-ui@latest add dialog`.

Install the three with `npx wingo-ui@latest add motion toast dialog`. All three are free.

## How do you test reduced motion?

Three places, from fastest to most realistic:

- **Chrome DevTools.** Open the Command Menu, run "Show Rendering", and set "Emulate CSS media feature prefers-reduced-motion" to `reduce`. The hook above updates live, since it subscribes to the query.
- **Playwright.** `browser.newContext({ reducedMotion: "reduce" })` runs the whole suite with the setting on. Add one test that hydrates a page and fails on console errors, and the mismatch above cannot come back.
- **A real phone.** Turn on Reduce Motion on iOS or Remove animations on Android and use the app for a minute. Sheets, toasts and route changes are where it shows.

## What should you do next?

Search your code for `useReducedMotion` and replace it with a `useSyncExternalStore` hook, add `<MotionConfig reducedMotion="user">` to your providers, and move hover and load animations to `motion-safe:`. Then walk through the app with the setting on and check that every state change is still visible. More posts on this stack are in [Next.js and Tailwind CSS](https://wingo-ui.com/blog/category/nextjs-tailwind), including the [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens) pillar, which covers motion tokens next to colors. Our [Framer Motion spring presets](https://wingo-ui.com/blog/framer-motion-spring-presets) post lists the four springs this fallback replaces, and the [next-themes hydration error fix](https://wingo-ui.com/blog/next-themes-hydration-error) applies the same server snapshot idea to the theme toggle.

## Components in this post

- [Motion presets](https://wingo-ui.com/components/motion) (Free): The shared springs, enter and exit presets, stagger helpers and reduced-motion hooks every animated component uses. Install: `npx wingo-ui@latest add motion`
- [Toast](https://wingo-ui.com/components/toast) (Free): React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read. Install: `npx wingo-ui@latest add toast`
- [Dialog](https://wingo-ui.com/components/dialog) (Free): A React modal dialog for focused tasks: a spring-scaled card on desktop and a bottom sheet on phones, with nesting and scrolling. Install: `npx wingo-ui@latest add dialog`

## FAQ

### Why does useReducedMotion cause a hydration mismatch?

Motion's useReducedMotion returns null on the server, where no media query exists, and the browser's real answer on the first client render. When that value picks an initial style or an attribute, the server HTML and the hydration render disagree and React logs a mismatch in development.

### Does MotionConfig reducedMotion="user" cause hydration errors?

No. Motion reads the preference when each motion element mounts in the browser, and nothing about it is in the rendered markup, so the server HTML and the hydration render match. It makes transforms and layout animations jump to their end while opacity and colors keep animating.

### How do I use prefers-reduced-motion in Tailwind CSS?

Prefix animations and transitions with motion-safe:, which compiles to @media (prefers-reduced-motion: no-preference), or switch them off with motion-reduce:. Both are plain CSS, so they apply on the first paint with no JavaScript.

### Should reduced motion turn off all animations?

No. The setting asks for less movement, and feedback still matters: keep opacity and color changes short and drop the travel, scaling, parallax and autoplay. A toast that fades in still tells the user something happened.

### Is respecting prefers-reduced-motion required by WCAG?

Success criterion 2.3.3 Animation from Interactions, at level AAA, asks that motion triggered by interaction can be disabled unless it is essential. Level A criterion 2.2.2 already requires a way to pause, stop or hide motion that starts on its own and lasts more than five seconds.

---

Source: https://wingo-ui.com/blog/prefers-reduced-motion-react
