Animations should guide attention, not hurt responsiveness.
A page with a 2-second LCP and smooth animations feels slower than a page with a 1-second LCP and no animations. The animations magnify the perception of latency.
The inverse is also true: thoughtful motion hides latency. A 500ms fade-in makes 500ms of loading feel intentional, not broken.
But motion must never compete with user input for the main thread.
Build on GPU-Friendly Transforms
Only two CSS properties animate cheaply: transform and opacity.
Everything else—width, height, left, right, padding, margin—triggers layout recalculation. The browser must recalculate sizes, recalculate positions, repaint, and composite. This is expensive and blocks input.
Use Transforms for Position and Scale
/* Good: GPU-accelerated */
@keyframes slideIn {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.modal {
animation: slideIn 0.3s ease-out;
}
/* Bad: Triggers layout recalculation */
@keyframes slideIn {
from {
left: -100%; /* Layout recalc on every frame */
}
to {
left: 0;
}
}
will-change: Use Sparingly and Remove After
will-change tells the browser "get ready to animate this." The browser may promote it to a GPU layer, making animation cheaper.
But promoting to GPU has memory cost. Use it only for elements you actually animate, and remove it after.
// Before animation
element.style.willChange = "transform";
// Start animation
element.classList.add("animate");
// After animation ends
element.addEventListener(
"animationend",
() => {
element.style.willChange = "auto";
},
{ once: true },
);
Keep Animations Short
Long animations tie up the thread. Keep keyframe duration under 400ms unless it is a deliberate transition (page navigation, loading state).
/* Good */
.button:hover {
animation: buttonHover 0.15s ease-out;
}
/* Bad: long, ties up thread */
.button:hover {
animation: buttonHover 2s ease-out;
}
Respect User Preferences
Some users have vestibular disorders, epilepsy, or migraines triggered by motion. Others are on slow devices and cannot afford the CPU cost.
Honor prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
In practice, this disables animations (0.01ms is imperceptible). Or replace with instant or fade:
@media (prefers-reduced-motion: reduce) {
.modal {
animation: modalFadeIn 0.1s ease-out;
}
}
@media (prefers-reduced-motion: no-preference) {
.modal {
animation: modalSlideIn 0.3s ease-out; /* Slide for users who want it */
}
}
Provide skip controls for looping or marquee content:
function Carousel({ autoplay = true }) {
const [paused, setPaused] = useState(false);
return (
<div>
<div
className={paused ? 'pause' : 'play'}
style={{ animationPlayState: paused ? 'paused' : 'running' }}
>
{/* Carousel items */}
</div>
<button onClick={() => setPaused(!paused)}>
{paused ? 'Resume' : 'Pause'}
</button>
</div>
);
}
Sequence Smartly
Animating everything at once is flashy but expensive. Batch and stagger:
/* Bad: All items animate together, long task on main thread */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.list-item {
animation: fadeIn 0.3s ease-out;
}
/* Good: Staggered, keeps each frame cheap */
.list-item {
animation: fadeIn 0.3s ease-out;
animation-delay: calc(var(--index) * 0.1s);
}
Keep stagger batches small (3–5 items at a time) to avoid long main-thread blocks.
Pause animations when offscreen:
function LazyAnimatedList({ items }) {
const [visibleItems, setVisibleItems] = useState([]);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
// Only animate visible items
setVisibleItems(items);
} else {
setVisibleItems([]);
}
},
{ threshold: 0.1 }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [items]);
return (
<ul ref={ref}>
{visibleItems.map((item) => (
<li key={item.id} className="fade-in">
{item.name}
</li>
))}
</ul>
);
}
Profile Motion During INP-Heavy Interactions
Animations on buttons, menus, and drawers are common places where motion and user input collide.
Profile INP during these interactions:
import { onINP } from "web-vitals";
onINP(({ value, attribution }) => {
if (value > 200) {
console.warn("INP violation during:", attribution.name);
// Example: "modal-open-animation"
}
});
If motion is the culprit, either:
- Shorten the animation.
- Move it off the main thread (Web Worker, requestIdleCallback).
- Replace it with a faster alternative (fade instead of slide).