Guide

How to respect prefers-reduced-motion in Framer Motion

Some people get dizzy, nauseous or distracted from large on-screen movement, and they turn on Reduce Motion in their operating system. Browsers expose that choice as the prefers-reduced-motion media query. Framer Motion gives you two tools to honor it, useReducedMotion and MotionConfig, and this guide covers when each one works, why a global CSS rule is not enough, and how the components in beUI handle it in practice.

Why the CSS media query cannot stop Framer Motion springs

A common first step is a global rule inside @media (prefers-reduced-motion: reduce) that sets animation-duration and transition-duration to almost zero on every element. beUI ships that rule too, and it is worth having: it calms CSS transitions and keyframe animations across the site.

It does not reach Framer Motion. A spring is calculated in JavaScript frame by frame and the result is written to the element's style, so there is no CSS transition for the media query to shorten. The same is true for anything you drive with requestAnimationFrame, useSpring or the imperative animate function. To stop that movement you have to read the preference in JavaScript and change what you animate.

Using useReducedMotion to drop transforms and keep opacity

useReducedMotion is a hook from motion/react that returns true when the user has asked for reduced motion. Its type is boolean or null, because the value is not known during server rendering, so beUI components read it as useReducedMotion() ?? false.

The right response is usually fewer and gentler animations, not none. Movement is what causes discomfort: translation, scaling, rotation, parallax and large layout morphs. A short opacity fade or a color change still helps people notice that something changed. So branch the animation: with reduced motion on, keep the fade and drop the transform, and swap the spring for a short tween.

TSXtoast.tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";

export function Toast({ children }: { children: ReactNode }) {
  // true when the OS setting is on, false when off, null before it is known.
  const reduce = useReducedMotion() ?? false;

  return (
    <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", stiffness: 420, damping: 32 }
      }
    >
      {children}
    </motion.div>
  );
}

MotionConfig reducedMotion versus per-component checks

MotionConfig sets defaults for every motion component below it. Its reducedMotion prop accepts "user", "always" or "never". With "user", Framer Motion follows the device setting and disables transform and layout animations for people who ask for reduced motion, while other values such as opacity and color still animate. Wrapping your app once is a cheap safety net.

It has limits. It does not change code you run yourself, such as an imperative animate call in an effect, a useSpring you read every frame, or a hand-written requestAnimationFrame loop. It also cannot redesign an interaction: a drum that spins, a drag with momentum or a scroll-linked parallax needs a different behavior, not just a disabled transform. Use MotionConfig as the baseline and useReducedMotion where the component needs a real alternative.

TSXproviders.tsx
"use client";

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

// "user" follows the OS setting: transform and layout animations are
// disabled for people who ask for reduced motion, opacity and color still run.
export function Providers({ children }: { children: ReactNode }) {
  return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}

Gating imperative animations and springs

Anything you start by hand needs its own check. Read the hook, and return early from the effect when it is true. An error shake is a good example: under reduced motion the red text and the message already explain the error, so the shake can simply be skipped.

For a control with several animated children, a MotionConfig with a transition override is a tidy pattern. The beUI Switch wraps its button in MotionConfig with transition set to duration 0 when reduced motion is on and to its thumb spring otherwise, and it also skips the press squish and the disabled shake.

TSXshake.tsx
"use client";

import { animate, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";

export function Shake({ error }: { error: boolean }) {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    // Imperative animations are not covered by MotionConfig. Gate them here.
    if (!error || !ref.current || reduce) return;
    const controls = animate(
      ref.current,
      { x: [0, -6, 6, -3, 0] },
      { duration: 0.4 },
    );
    return () => controls.stop();
  }, [error, reduce]);

  return <div ref={ref} className={error ? "text-red-600" : undefined}>Check the code</div>;
}

How beUI components handle reduced motion

Every beUI component that moves reads useReducedMotion from motion/react, because the global CSS rule cannot stop its springs. The answer differs by component, which is the point: each one keeps what helps comprehension and removes what moves.

  • Wheel Picker swaps the whole 3D drum for a flat list of native buttons in a CSS scroll-snap container. No rotation, no momentum, no drag physics.
  • Message drops the trailing-edge pop-up that translates and scales a new row, so the row simply appears in place, and a removed row fades out over 120 ms instead of shifting.
  • Adaptive Stepper sets the rolling distance to zero, removes the blur and makes the value change instant, while its live region still announces the new value.
  • Switch uses MotionConfig with a zero-duration transition so the thumb jumps instead of springing, and skips the press squish and the disabled shake.
TSXswitch-pattern.tsx
import { motion, MotionConfig, useReducedMotion } from "motion/react";

const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;

export function Thumb({ checked }: { checked: boolean }) {
  const reduce = useReducedMotion();
  return (
    // One switch for every motion child: springs normally, instant when reduced.
    <MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
      <motion.span layout className={checked ? "ml-auto size-5" : "size-5"} />
    </MotionConfig>
  );
}

Testing reduced motion and other accessibility checks

Turn the setting on and use the interface. On macOS it is in System Settings under Accessibility, Display, Reduce motion; on iOS under Accessibility, Motion; on Windows under Accessibility, Visual effects, Animation effects. Chrome DevTools can also emulate prefers-reduced-motion from the Rendering panel, which is the fastest loop while you work.

Check the things automated tools miss. Make sure nothing still slides, scales, spins or scrolls smoothly on its own, that state changes are still visible through color or opacity, and that focus still lands in the right place when an overlay opens without its animation. If you smooth-scroll in JavaScript, switch to instant scrolling under reduced motion; beUI's Message Scroller does that.

beUI components install with the shadcn CLI and keep this handling in their source, so you can read and adjust it.

ShellTerminal
npx shadcn@latest add @beui/wheel-picker @beui/message @beui/adaptive-stepper @beui/switch

Components in this guide

FAQ

How do I detect prefers-reduced-motion in React?
With Framer Motion, call useReducedMotion from motion/react. Without it, use window.matchMedia("(prefers-reduced-motion: reduce)") and listen for its change event so the UI updates when the setting changes.
Does Framer Motion respect reduced motion automatically?
Not by default. Wrap your app in MotionConfig with reducedMotion set to "user" to disable transform and layout animations for people who ask for reduced motion, or read useReducedMotion in each component for finer control.
Why does my animation still play with Reduce Motion turned on?
Framer Motion springs and imperative animations run in JavaScript, so a CSS rule that shortens transition-duration has no effect on them. Gate them with useReducedMotion, and check any animate calls or requestAnimationFrame loops you start yourself.
Should reduced motion remove all animation?
Usually not. The guidance is to remove movement such as translation, scaling, rotation and parallax, and keep short opacity or color transitions that help people notice a change. Removing all feedback can make an interface harder to follow.
What does useReducedMotion return on the server?
It returns null before the preference is known, so treat it as a boolean with a fallback, for example useReducedMotion() ?? false, and make sure the first render looks correct either way.

Updated