Web Animation Performance: A 60fps Guide
Janky animation is not a style choice - it is a performance bug. Most animation jank comes from animating the wrong CSS properties, which forces the browser to redo expensive layout work every frame. This guide explains the rendering pipeline in practical terms, which properties are cheap to animate, and the techniques that keep interfaces at a smooth 60 frames per second.
The Short Answer
- Animate transform and opacity only. Everything else (width, height, top, left, margin, box-shadow) triggers layout or paint work that drops frames.
- The browser pipeline is JavaScript > Style > Layout > Paint > Composite. Cheap animations skip straight to Composite.
- Use
will-changesparingly - on the element, just before the animation, then remove it. - For layout animations (reordering lists, expanding panels), use the FLIP technique: record First/Last positions, Invert, Play.
- Measure with Chrome DevTools Performance panel and the Rendering tab's frame-rate meter - never trust your eyes on a fast machine.
1. The Rendering Pipeline in 60 Seconds
Every frame, the browser may do up to five steps:
- JavaScript - your animation code runs.
- Style - the browser recalculates which styles apply.
- Layout - it computes geometry: positions and sizes of elements. This is the expensive one; layout of one element can invalidate its whole subtree.
- Paint - it fills in pixels: text, colors, images, shadows, borders.
- Composite - it draws the pre-painted layers to the screen in the right order.
A 60fps animation has 16.7 milliseconds per frame for all of this. Animations that only need step 5 (composite) almost always hit the budget. Animations that need steps 3-5 usually do not.
The practical consequence: the property you animate determines the cost.
| Property | Pipeline cost | Verdict |
|---|---|---|
| transform (translate, scale, rotate) | Composite only | Animate freely |
| opacity | Composite only | Animate freely |
| color, background-color | Paint + composite | OK for small areas |
| box-shadow, border-radius | Paint + composite | Avoid on large areas |
| width, height, top, left, margin, padding | Layout + paint + composite | Never animate directly |
2. The Core Techniques
Move things with transform, not position. Instead of animating left: 0 to left: 300px (layout every frame), animate transform: translateX(0) to transform: translateX(300px) (composite only). Same visual result, fraction of the cost.
/* Bad: triggers layout */
.panel { transition: left 0.3s ease; }
.panel.open { left: 300px; }
/* Good: compositor only */
.panel { transition: transform 0.3s ease; }
.panel.open { transform: translateX(300px); }
Fade with opacity. Opacity is the other compositor-only property. Cross-fades, hover states, and enter/exit transitions should use it.
Prefer CSS transitions/animations over JavaScript for simple cases - the browser can optimize them, including running them on the compositor thread without involving the main thread. Use the Web Animations API (element.animate()) when you need JavaScript control with the same performance characteristics.
Contain the damage. When you must animate a paint-level property, use CSS containment (contain: layout paint) to limit invalidation to the element's subtree.
3. will-change: Powerful and Dangerous
will-change: transform tells the browser to promote the element to its own compositor layer ahead of time, avoiding a costly promotion mid-animation.
.card {
/* Added via JS right before the animation starts */
}
.card.animating { will-change: transform; }
The danger: every promoted layer consumes GPU memory. will-change on dozens of elements - or left on permanently - causes the exact jank it was meant to prevent. Rules: apply it just before the animation, remove it when done, and never put it in a global stylesheet.
4. FLIP: Animating Layout Changes
Some animations are inherently about layout: a list reordering, a grid item expanding, an element moving between containers. FLIP makes these cheap:
- First - record the element's starting position/size.
- Last - apply the final state and record the ending position/size.
- Invert - apply a transform that visually puts the element back at First.
- Play - animate the transform to zero.
The browser only ever animates a transform, so it stays on the compositor. Libraries exist for this, but the technique is simple enough to hand-roll for one-off cases:
const first = el.getBoundingClientRect();
// ... apply the layout change ...
const last = el.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
{ duration: 300, easing: 'ease-out' }
);
5. Measuring: Trust Instruments, Not Eyes
Your development machine is fast; your users' are not. Measure:
- DevTools Performance panel: record an interaction, look for long frames (red bars) and what caused them (layout, paint).
- Rendering tab > Frame Rendering Stats: a live FPS meter overlay.
- Rendering tab > Paint flashing: highlights repainted areas in green. If the whole page flashes green during your animation, something is wrong.
- CPU throttling: DevTools lets you simulate a 4x-6x slower CPU. If the animation survives throttling, it will survive real users.
6. Animation UX Rules
Performance is half the job; the other half is restraint:
- Duration: 150-300ms for UI feedback (hovers, toggles); 300-500ms for larger transitions (page changes, modals). Longer feels sluggish, not luxurious.
- Easing: ease-out for things entering, ease-in for things leaving, ease-in-out for things moving within the view. Linear only for continuous motion (spinners, progress).
- Respect prefers-reduced-motion. Some users experience motion sickness or vestibular disorders. Provide a reduced alternative:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
- Do not animate everything. Motion draws attention; if everything moves, nothing does. Animate state changes and feedback, not decoration.
7. Case Study: Fixing a Janky Modal
A real-world-shaped example. A settings modal animates in with a fade-and-scale effect, and it stutters on mid-range phones. Diagnosis with the Performance panel shows the problem in one record:
Before. The animation transitions width, height, and opacity:
.modal {
transition: width 0.3s ease, height 0.3s ease, opacity 0.3s ease;
}
Every frame triggers layout (width/height), then paint, then composite. The Performance panel shows long frames with purple "Layout" blocks dominating. On a throttled CPU the animation runs at ~20fps.
After. The same visual effect with transform and opacity only. The modal is rendered at its final size; the animation scales from 0.95 and fades in:
.modal {
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1), opacity 0.3s ease;
}
.modal[hidden] {
transform: scale(0.95);
opacity: 0;
}
The Performance panel now shows green "Composite" work only, frames fit in the 16.7ms budget, and the animation holds 60fps on the throttled CPU. The visual result is nearly identical - slightly different easing aside, no user can tell which properties were animated. They can only tell that one version stutters.
The general method. When any animation janks: record it in the Performance panel, find the long frames, read what the browser was doing (Layout? Paint?), and replace the offending properties with transform/opacity equivalents. Nine times out of ten, that is the whole fix.
8. Reduced Motion and Accessibility
Motion can harm as well as delight. Some users experience vestibular disorders, and parallax or large-scale motion can cause nausea or dizziness. The platform provides a preference; respect it:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
This is the standard blanket rule: with reduced motion requested, animations effectively become instant state changes. Content still appears; it just does not travel.
Design for the static state first. Every animation should have a meaningful start and end state that work as static designs. If hiding content until an animation reveals it, ensure the no-motion experience still shows the content. Elements that only exist mid-animation are invisible to reduced-motion users - and to anyone whose browser fails to run the animation.
Avoid the worst offenders. Large parallax backgrounds, spinning or zooming hero elements, and perpetual motion (endless carousels, floating everything) are the most likely to cause problems. Use them sparingly even for users without motion sensitivity - perpetual animation is also a distraction and a battery drain.
Autoplay with care. Auto-playing motion should pause on hover/focus and never be the only way to access content. A carousel that moves on its own must have visible pause controls.
Frequently Asked Questions
Why is my CSS animation janky? Almost always because you are animating a layout or paint property (width, height, top, left, box-shadow). Switch to transform and opacity.
Is will-change always good? No. It trades GPU memory for animation smoothness. Overuse causes jank. Apply before the animation, remove after.
CSS animations or JavaScript? CSS transitions/animations for simple state changes; the Web Animations API when JavaScript needs control. Avoid animating via requestAnimationFrame style writes to layout properties.
How do I animate height for accordions?
Animate a transform instead where possible, or use the grid-template-rows trick (grid-template-rows: 0fr -> 1fr), which browsers now animate smoothly. FLIP also works.
What frame rate should I target? 60fps (16.7ms per frame) is the standard target. High-refresh displays go to 120fps, but 60fps smooth on a throttled CPU is the practical bar.
Continue Reading
- Icon Design Principles - SVG icons that animate cleanly
- Building a Design System from Scratch - motion tokens