Accessibility & performance
For some users, your animation is a medical event. Respect that.
Two non-negotiable requirements: respect motion preferences and keep 60fps.
## prefers-reduced-motion
```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;
}
}
```
A more targeted approach:
```css
@media (prefers-reduced-motion: reduce) {
.modal {
/* Remove scale and translate; keep a fast opacity fade */
animation: none;
transition: opacity 100ms ease;
}
}
```
Reduced motion users don't want _zero_ feedback. They want animation that doesn't _move across the screen_.
### In JavaScript
```tsx
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const transition = prefersReducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 40 };
```
Or use Motion's `useReducedMotion` hook:
```tsx
import { useReducedMotion } from "motion/react";
function Modal() {
const shouldReduce = useReducedMotion();
return (
<motion.div
initial={
shouldReduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 12 }
}
animate={shouldReduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
transition={shouldReduce ? { duration: 0.1 } : { duration: 0.25 }}
/>
);
}
```
## Transform and opacity only
`transform` and `opacity` run entirely in the composite stage. Everything else triggers layout or paint.
**Animate `transform` and `opacity`. Nothing else.**
| Property | Triggers | Performance |
| -------------------------------------- | -------------- | ------------------------------ |
| `transform` (translate, scale, rotate) | Composite | Smooth |
| `opacity` | Composite | Smooth |
| `filter` (blur, brightness) | Paint | Test on mobile |
| `box-shadow` | Paint | Can stutter with large spreads |
| `background-color` | Paint | Avoid during drag |
| `width`, `height`, `top`, `left` | Layout + Paint | Never animate |
| `border-radius` | Paint | Never animate |
| `clip-path` (inset, circle) | Composite | Smooth with `will-change` |
}
/>
## will-change: use sparingly
```css
.about-to-animate {
will-change: transform;
}
```
`will-change: transform` on 200 list items creates 200 compositor layers.
1. **Apply** before animation starts (on `mouseenter`, modal about to open).
2. **Remove** after animation completes (on `transitionend`).
3. **Never** apply in a static stylesheet to elements that "might" animate.
```tsx
// Apply on hover, remove on animation end
<div
onMouseEnter={() => (el.style.willChange = "transform")}
onTransitionEnd={() => (el.style.willChange = "auto")}
/>
```
## Off-screen pausing
```tsx
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
controls.start("visible");
} else {
controls.stop();
}
},
{ threshold: 0.1 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [controls]);
```
For CSS animations, toggle `animation-play-state: paused` when not intersecting.
## Layout animation gotchas
Motion's `layout` prop on fifty elements means fifty position recalculations per frame.
- Use `layout` on individual hero elements, not every list item.
- Use `layoutId` for shared-element transitions between two specific elements.
- For list reordering, use `layout="position"` (skips size animation).
- Set `layoutScroll` on scrolling containers. Without it, scroll position is ignored.
## The performance test
Test on a two-year-old Android phone. Enable paint flashing in DevTools. Below 50fps? Simplify.