Most sites handle reduced motion with one CSS rule. beUI's site has it too:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } It's worth having, but on a Framer Motion site it isn't enough. Turn on "Reduce motion" with only this rule in place, and drawers still slide in, tabs still spring and counters still roll.
That CSS only shortens CSS animations and transitions. Framer Motion works out each frame of a spring in JavaScript and writes the result to the element's style, so the media query never sees it.
I build beUI, a library of animated React components, and more than 150 of its files now read the reduced motion setting. Here's what I learned doing that.
Start with the hook, and handle null
import { useReducedMotion } from "motion/react"; const reduce = useReducedMotion() ?? false; useReducedMotion() returns true when the setting is on. It returns null during server rendering, because the server can't know. The ?? false gives you a plain boolean, so you don't have to remember the third case everywhere.
Pattern 1: fade, don't freeze
Reduced motion doesn't mean no animation. People turn the setting on because movement makes them dizzy or sick: sliding, scaling, spinning, parallax. A short fade doesn't cause that, and it still shows that something changed.
So keep the opacity and drop the transform:
<motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }} transition={reduce ? { duration: 0.15 } : { type: "spring", duration: 0.4, bounce: 0.2 }} /> Swap the spring for a short tween too. Even a fade feels odd when it wobbles.
For a component with a lot of motion children, you don't need to branch every one. beUI's tabs wrap the whole thing once:
<MotionConfig transition={reduce ? { duration: 0 } : transition}> {/* the sliding pill and every child animation */} </MotionConfig> Pattern 2: some interactions need a different design
Turning off a transform works for a toast. It doesn't work for an iOS-style wheel picker, where the whole point is a drum you fling and watch spin.
If you only remove the animation, you get a drum that jumps between values, which is confusing. So under reduced motion, beUI's wheel picker renders something else entirely: a plain list of buttons in a scroll container with CSS snap points. Same options, same value, same onValueChange, and nothing spins.
Even the sound changed. The drum plays a tick for every row it passes. The reduced version has no rows passing, so it plays one tick when the value is picked.
Ask what the movement is for. If it's the interaction itself, design a calm version of it instead of switching it off.
Pattern 3: decoration just turns off
Some motion only exists to look nice. Under reduced motion it goes away completely:
- Magnetic buttons that follow the cursor stay still.
- Parallax layers get no transform and scroll with the page.
- Smooth scrolling (beUI uses Lenis) turns off and falls back to the browser's normal scroll, and
scrollTojumps straight to the target.
const style = reduce ? {} : { y: drift }; Nobody misses a parallax effect, but plenty of people feel sick from one.
Pattern 4: check everything you start yourself
The hook only helps where you check it. These are easy to miss:
- Imperative animations. An
animate()call inside auseEffectignoresMotionConfigand your variants. Return early whenreduceis on. - Native smooth scroll.
element.scrollBy({ behavior: "smooth" })is motion too. beUI's tabs usebehavior: reduce ? "instant" : "smooth"when scrolling a tab into view. -
requestAnimationFrameloops. Same deal, check the flag before you start.
What about MotionConfig reducedMotion="user"?
<MotionConfig reducedMotion="user">{children}</MotionConfig> This tells Framer Motion to follow the device setting and skip transform and layout animations for everything inside it. It's a good safety net, and you should add it.
But it can't do pattern 2. It doesn't know your wheel picker should become a list. It also doesn't reach the imperative code in pattern 4. Treat it as the floor, and use the hook where a component needs real thought.
How to test it
- macOS: System Settings, Accessibility, Display, Reduce motion.
- Windows: Settings, Accessibility, Visual effects, Animation effects off.
- Chrome DevTools: Command menu (Cmd/Ctrl+Shift+P), type "reduced motion", pick "Emulate CSS prefers-reduced-motion: reduce".
useReducedMotionpicks this up too.
Then click through the whole page, not just the component you changed. Overlays, counters and scroll effects are the usual stragglers.
The short version
- CSS can't stop JavaScript springs. Use
useReducedMotion() ?? false. - Keep fades, drop movement.
- If the movement is the interaction, design a calm version.
- Turn decoration off completely.
- Check every animation you start by hand.
beUI's components are built with these rules, so if you'd rather not do this work yourself, you can install them with the shadcn CLI:
npx shadcn@latest add @beui/<component> Source is on GitHub. And if you use reduced motion yourself, I'd like to hear which animations bother you most.
