Accessibility & Performance
10 minAccessibility & Performance
Animation exists to communicate. But for some users, motion isn't communication—it's a trigger for vestibular discomfort, nausea, or seizures. And for all users, animation that drops frames or causes layout recalculation feels worse than no animation at all.
This lesson covers two non-negotiable requirements: respecting the user's motion preference and keeping animations performant. Neither is optional. Neither is hard to implement. Both are routinely ignored.
prefers-reduced-motion
Every operating system provides a user-level setting to reduce motion. macOS calls it "Reduce motion." Windows calls it "Show animations." iOS and Android have equivalent settings. The CSS media query prefers-reduced-motion: reduce detects this preference.
If a user has enabled reduced motion, your animation is not a design choice—it's an accessibility violation. Respect the setting.
@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;
}
}
This nuclear approach—setting all animation durations to near-zero—is the simplest implementation and a reasonable starting point. It preserves the end state of every animation (the element still reaches its destination) while removing the motion (it gets there instantly).
A more nuanced approach reduces motion rather than eliminating it:
@media (prefers-reduced-motion: reduce) {
.modal {
/* Remove scale and translate; keep a fast opacity fade */
animation: none;
transition: opacity 100ms ease;
}
}
This preserves feedback (the user still sees the modal appear) while eliminating spatial motion (the scale and translate that can trigger discomfort). The distinction matters—users who set reduced motion generally don't want zero visual feedback. They want animation that doesn't move across the screen.
In JavaScript
For imperative animations (Framer Motion, GSAP, Web Animations API), check the preference programmatically:
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
// Use instant transitions when reduced motion is preferred
const transition = prefersReducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 40 };
Framer Motion supports this natively with the useReducedMotion hook:
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
The browser's rendering pipeline has three stages: layout, paint, and composite. transform and opacity are the only CSS properties that can be animated entirely in the composite stage—meaning the GPU handles them without triggering layout recalculation or pixel repaint.
Everything else—width, height, top, left, margin, padding, border-radius, box-shadow, background-color—triggers layout or paint (or both) on every frame. This means the browser must recalculate the position of every element on the page, every 16ms, for the duration of the animation.
The practical rule: animate transform and opacity. Animate nothing else.
This isn't a guideline. It's a performance boundary. On a modern desktop, animating width might run at 60fps if nothing else is happening. On a mid-range mobile device with a complex DOM, it will stutter. On any device, a transform animation will run smoothly because it's handled by dedicated GPU hardware.
| Property | Triggers | Performance |
|---|---|---|
| transform (translate, scale, rotate) | Composite only | Smooth |
| opacity | Composite only | Smooth |
| filter (blur, brightness) | Paint | Usually fine, test on mobile |
| box-shadow | Paint | Can stutter with large spreads |
| background-color | Paint | Fine for discrete changes, avoid during drag |
| width, height, top, left | Layout + Paint | Never animate |
| border-radius | Paint | Never animate |
| clip-path (inset, circle) | Composite (modern browsers) | Smooth with will-change |
will-change: use sparingly
will-change tells the browser to promote an element to its own compositor layer before the animation starts. This eliminates the one-time cost of layer promotion that can cause a single-frame stutter at the start of an animation.
.about-to-animate {
will-change: transform;
}
But will-change is not free. Each promoted layer consumes GPU memory. Applying will-change: transform to every element in a list of 200 items creates 200 compositor layers, which can exhaust GPU memory on mobile devices and actually make performance worse.
The correct usage pattern:
- Apply
will-changeimmediately before the animation starts (e.g., onmouseenterfor a hover animation, or when a modal is about to open). - Remove
will-changeafter the animation completes (ontransitionendoranimationend). - Never apply
will-changein a static stylesheet to elements that "might" animate. If the animation doesn't happen, the GPU memory is wasted.
// Apply on hover, remove on animation end
<div
onMouseEnter={() => el.style.willChange = "transform"}
onTransitionEnd={() => el.style.willChange = "auto"}
/>
Off-screen pausing
Animations that run while off-screen waste CPU and GPU cycles. A continuously rotating spinner below the fold, an auto-playing carousel above the viewport, a looping background animation on a hidden tab—all consume resources the user can't see.
Use the Intersection Observer API to pause animations when their element leaves the viewport:
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 a class that sets animation-play-state: paused when the element is not intersecting.
This is especially important for page-transition animations that play on mount. If a user navigates to a long page, elements at the bottom should not animate until they scroll into view—both for performance (don't animate what can't be seen) and for user experience (the animation is meaningless if the user wasn't looking).
Layout animation gotchas
Framer Motion's layout prop enables automatic layout animations—elements smoothly transition between different positions and sizes in the DOM. This is powerful and dangerous.
Layout animations trigger browser layout recalculation on every frame. For a single element, this is fine. For a list of 50 elements with layout props, the browser recalculates the position of all 50 elements 60 times per second. This will stutter on any device.
Mitigations:
- Use
layouton individual hero elements, not on every item in a list. - Use
layoutIdfor shared-element transitions between two specific elements. - For list reordering, use
layout="position"(skips size animation, which is the most expensive part). - Set
layoutScrollon scrolling containers that contain layout-animated elements—without it, the layout calculation ignores scroll position and produces incorrect transitions.
The performance test
Before shipping any animation, test on a low-end device. Not your M-series MacBook—a two-year-old Android phone with 4GB of RAM. Enable paint flashing in DevTools. If green rectangles appear during your animation, you're triggering paint. If the animation drops below 50fps on that device, simplify it.
The user who needs your animation to be performant is never the user with the fastest hardware. Build for the floor, not the ceiling.