Accessible animation: designing for prefers-reduced-motion in React and Expo

What prefers-reduced-motion means, which animations to change, and how to implement reduced-motion fallbacks with Motion on the web and Reanimated in Expo.

Moveset team··2 min read

Some people experience nausea, dizziness or distraction from on-screen motion, especially large movements, parallax and zooming. Every major operating system has a setting to reduce motion, and browsers expose it to CSS and JavaScript as prefers-reduced-motion. Honouring it is not optional polish; it is part of building usable UI.

What the guidelines say

WCAG 2.1 success criterion 2.3.3 Animation from Interactions (level AAA) asks that motion triggered by interaction can be disabled unless it is essential. Criterion 2.2.2 Pause, Stop, Hide (level A) covers content that moves automatically for more than five seconds. Respecting the OS setting is the most direct way to meet the intent of both.

Reduce, don't remove

"Reduced motion" does not mean "no feedback". The state change must still be visible. Good substitutions:

  • Slides and zooms become a short cross-fade or an instant change.
  • Parallax and scroll-linked camera moves become static layouts.
  • Auto-rotating 3D objects stop, but can still be rotated by direct drag.
  • Spring overshoot is removed; the value arrives without bouncing.
  • Essential motion, such as a progress indicator, stays, ideally in a calmer form.

CSS first

For CSS animations and transitions, a global media query is the simplest safety net:

css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

React with Motion

Motion's useReducedMotion() hook returns true when the user prefers reduced motion. Moveset's components branch on it so the same component degrades gracefully:

tsx
import { motion, useReducedMotion } from "motion/react";

export function Reveal({ children }: { children: React.ReactNode }) {
  const reduce = useReducedMotion();
  return (
    <motion.div
      initial={reduce ? { opacity: 0 } : { y: 24, opacity: 0 }}
      whileInView={reduce ? { opacity: 1 } : { y: 0, opacity: 1 }}
      transition={reduce ? { duration: 0.2 } : { type: "spring", stiffness: 220, damping: 26 }}
      viewport={{ once: true }}
    >
      {children}
    </motion.div>
  );
}

For a whole app, <MotionConfig reducedMotion="user"> tells Motion to skip transform and layout animations automatically when the setting is on, while keeping opacity and colour changes.

Expo with Reanimated

Reanimated exports its own useReducedMotion() that reads the device's accessibility setting synchronously:

tsx
import { useReducedMotion, withSpring } from "react-native-reanimated";

const reduce = useReducedMotion();
progress.value = reduce ? target : withSpring(target, { stiffness: 220, damping: 26 });

Recent Reanimated versions also let you configure reduced-motion behaviour per animation; check the docs for your installed version.

Three.js and canvas scenes

Canvas-based animation doesn't see CSS media queries, so read the preference in JavaScript:

ts
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

Moveset's 3D components check this once at load and disable auto-rotation and floating loops when it is set.

How to test

  • macOS: System Settings, Accessibility, Display, Reduce motion.
  • Windows: Settings, Accessibility, Visual effects, Animation effects off.
  • iOS: Settings, Accessibility, Motion, Reduce Motion.
  • Android: Settings, Accessibility, Remove animations (wording varies by manufacturer).
  • Chrome DevTools: Rendering panel, "Emulate CSS media feature prefers-reduced-motion".

Moveset's web components and 3D scenes, free and Pro, ship with a reduced-motion path, so you start accessible instead of retrofitting it.