Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Next.js and Tailwind CSS
  4. Prefers Reduced Motion React: Calm Motion, No Mismatch
Auf Deutsch lesen
  1. Blog
  2. Next.js and Tailwind CSS
  3. Prefers Reduced Motion React: Calm Motion, No Mismatch
Next.js and Tailwind CSS
Next.js and Tailwind CSS

Prefers Reduced Motion React: Calm Motion, No Mismatch

SR
Serban Rusu · Founder of Wingo UI
Oct 13, 2026 · 8 min read

On this page

0%
  1. What does prefers-reduced-motion ask for?
  2. How do you handle prefers-reduced-motion in Tailwind?
  3. Why does useReducedMotion cause a hydration mismatch?
  4. Is MotionConfig reducedMotion="user" enough?
  5. How do you write a hydration-safe usePrefersReducedMotion hook?
  6. What should replace springs under reduced motion?
  7. How do the Toast and Dialog handle reduced motion?
  8. How do you test reduced motion?
  9. What should you do next?
  10. FAQ
    1. Why does useReducedMotion cause a hydration mismatch?
    2. Does MotionConfig reducedMotion="user" cause hydration errors?
    3. How do I use prefers-reduced-motion in Tailwind CSS?
    4. Should reduced motion turn off all animations?
    5. Is respecting prefers-reduced-motion required by WCAG?

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.

Published Oct 13, 2026

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 (opens in a new tab). 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 (opens in a new tab) 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 (opens in a new tab), 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 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:

ApproachReduced motion onReduced motion offFollows 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 visitNo, read once into state
useSyncExternalStore hook, server snapshot falseClean; an entrance that plays during hydration runs in full onceCleanYes
<MotionConfig reducedMotion="user">Clean; transforms jump to the end, opacity still fadesCleanFor 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 (opens in a new tab) 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 hook instead of re-exporting Motion's. The React use mobile hook 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 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:

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
$ npx wingo-ui@latest add motion
FreeMotion presets docs

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 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:

Fire a few toasts, then try again with reduced motion turned on: they fade in at their slot instead of rising from the edge
$ npx wingo-ui@latest add toast
FreeToast docs
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 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.

Open the dialog with and without reduced motion: the card scales up on a spring, or fades in place
$ npx wingo-ui@latest add dialog
FreeDialog docs

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, including the Tailwind v4 design tokens pillar, which covers motion tokens next to colors. Our Framer Motion spring presets post lists the four springs this fallback replaces, and the next-themes hydration error fix applies the same server snapshot idea to the theme toggle.

Components in this post

  • Motion presets

    The shared springs, enter and exit presets, stagger helpers and reduced-motion hooks every animated component uses.

    Free
  • Toast

    React toast notifications: mount one Toaster and call toast() from anywhere, with stacking, swipe to dismiss and a pause while you read.

    Free
  • Dialog

    A React modal dialog for focused tasks: a spring-scaled card on desktop and a bottom sheet on phones, with nesting and scrolling.

    Free

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.

  • React
  • Accessibility
  • Animation
  • Next.js
  • Tailwind CSS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Comparisons and alternatives

Chakra UI vs shadcn in 2026: Style Props or Tailwind?

Chakra UI vs shadcn in October 2026: style props on Emotion or Tailwind source you own. Theming, Turbopack, Server Components and phones, with code.

SRSerban Rusu·Oct 16, 2026·10 min read
Next.js and Tailwind CSS

Next.js Component Library Setup for the App Router

Next.js component library setup for the App Router, tested on Next.js 16.4: use client boundaries, tokens in globals.css, dark mode without a flash, updates.

SRSerban Rusu·Oct 13, 2026·8 min read
Next.js and Tailwind CSS

shadcn Vite Setup with Tailwind CSS v4 and React 19

shadcn Vite setup tested on Vite 8 and TypeScript 6: the Tailwind v4 plugin, the @ alias in tsconfig.app.json, dark mode without Next.js, then add components.

SRSerban Rusu·Oct 13, 2026·7 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI