Accessible Animation
Animations are used to strategically improve an experience. To some people, they degrade it. Motion can make people feel sick — vestibular disorders are real and common — or simply pull attention away from the task. That's not the experience you're building.
Distilled from Emil Kowalski's Animations on the Web course (animations.dev). This skill covers reduced motion: reading the user's preference and deciding what each animation becomes when it's set.
Follow ../animate/references/canonical-policy.md for the shared motion policy. It wins if this guide conflicts with it.
The preference
Most devices let users state a preference for animation, and browsers expose it through the prefers-reduced-motion media query:
no-preference— the user has not set a preference. No change needed.reduce— the user has set a preference. Your animation must be altered.
Because the preference exists at the OS level, a user who set it once expects every site to honor it. There is no per-site opt-in to wait for.
Preserve meaning and reduce motion
reduce can mean an instant change, a non-spatial transition, or no decorative
animation. Choose the least motion that preserves the interaction's meaning.
The transformation is: remove the motion, keep the meaning.
Under reduce |
Do |
|---|---|
Movement — transform, translate, scale, position, layout |
Remove. Nothing should move. |
Meaning — opacity, color, background-color |
Keep. These carry the state change without motion. |
| Autoplaying and looping animation | Disable — or pause it on a representative frame. |
| Purely decorative motion (an idle float, an ambient loop) | Remove entirely. It conveys nothing, and lingering motion falsely implies interactivity. |
So a modal that scales in becomes a modal that fades in. A sidebar that slides from -100% becomes a sidebar that fades. A multi-step form that slides horizontally between steps crossfades instead. The state change is still legible; nothing travels across the screen.
Workflow
Design both preference states from the start:
- Design both preference states. Decide what information the motion carries.
- Implement
prefers-reduced-motion. Apply the table above. Test it with the preference on — Chrome DevTools can emulate it (Rendering panel → Emulate CSS media feature prefers-reduced-motion). - Ship two variants. One for
no-preference, one forreduce.
You're done when every animation you touched has both variants and you have watched the reduce variant with emulation on. Reasoning about it is not the same as seeing it — the common failure is a "reduced" variant that still moves, because one transform was left behind in a shared class or a spring config.
Implementation
CSS
Replace spatial motion when meaning benefits from a transition; otherwise disable the animation:
.element {
animation: bounce 0.2s;
}
@media (prefers-reduced-motion: reduce) {
.element {
animation: fade 0.2s;
}
}
Tailwind
motion-safe: and motion-reduce: variants map to the two media queries:
<svg class="motion-safe:animate-bounce motion-reduce:animate-none" viewBox="0 0 24 24">
<!-- ... -->
</svg>
Framer Motion — useReducedMotion
The hook returns true when the user prefers reduced motion, so you can branch individual values:
import { useReducedMotion, motion } from "motion/react";
export function Sidebar({ isOpen }) {
const shouldReduceMotion = useReducedMotion();
const closedX = shouldReduceMotion ? 0 : "-100%";
return <motion.div inert={!isOpen} aria-hidden={!isOpen} animate={{ opacity: isOpen ? 1 : 0, x: isOpen ? 0 : closedX }} />;
}
Motion 13.2's built-in hook reads the preference on mount. For live preference changes, use the external-store hook and worked example in SNIPPETS.md. A preference hook can also branch whole variant sets and gate properties CSS can't reach: skip height animation and pass layout={false} under reduce, since layout animations move things by definition.
Framer Motion — MotionConfig (app-wide safety net)
reducedMotion="user" makes Framer Motion respect the preference everywhere below it, disabling transform and layout animations while preserving other values, such as opacity and backgroundColor. It does not disable every possible spatial effect (for example, explicit height animation needs its own handling):
import { MotionConfig } from "motion/react";
<MotionConfig reducedMotion="user">{children}</MotionConfig>
The default is never, so this does nothing until you set it. Wrap the application as a baseline, then check every component override, explicit dimension animation, SVG effect, and decorative loop with useReducedMotion where needed.
Visuals: jump, don't tween
Anything that reads as an image or video — a visual metaphor explaining a concept — often can't be removed, because the motion is what carries the explanation.
Reduce it instead of deleting it: jump between the frames rather than animating between them. The user still gets every state of the sequence; nothing slides or morphs on screen.
Snippets
SNIPPETS.md has copy-ready recipes for the cases with a specific mechanism:
- Smooth scrolling — enable only under
no-preference - Autoplaying images (GIF / animated AVIF) — static fallback via
<picture> - Autoplaying video — paused with visible controls under
reduce - Looping animation — pause it on a hero frame rather than frame 0
- Dependency-free
useReducedMotionhook - Worked example: reduced-motion variants for a multi-step component
For the surrounding decisions — whether to animate at all, easing, duration, springs — see the animate skill. For frame budget and GPU concerns, see animation-performance.