Prefers Reduced Motion React: Calm Motion, No Mismatch

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:
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:
That is the useReducedMotion hydration problem in one row. The usual pattern looks harmless:
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:
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:
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:
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:
$ npx wingo-ui@latest add motionHow 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:
$ npx wingo-ui@latest add toastCall 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.
$ npx wingo-ui@latest add dialogInstall 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.
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


