Animation Performance
Date: 2026-08-17
Two properties animate without touching layout or paint —
transformandopacity— and everything else costs a full pipeline pass per frame. That’s the whole rule, and knowing why it’s true is what tells you which of the tempting alternatives are secretly fine.
Animation performance is whether an animation holds the display’s frame rate, which depends on how much of the rendering pipeline each frame forces the browser to redo.
The frame budget
60fps → 16.7ms per frame, INCLUDING the browser's own work
realistically ~10ms for yours
what each animated property costs, per frame
width, height, top, left, margin, padding
→ STYLE → LAYOUT → PAINT → COMPOSITE the whole pipeline
recalculates geometry for this element AND everything
its position affects
background-color, box-shadow, border-radius, color
→ STYLE → ─────── → PAINT → COMPOSITE skips layout
repaints the affected area
transform, opacity
→ ─────────────────────────── → COMPOSITE ← the goal
the layer already exists as a texture. the compositor
moves or fades it. often on a separate thread entirely
The last case can run while the main thread is blocked, which is why a CSS transition stays smooth during a heavy script and a JavaScript animation loop doesn’t — The Rendering Pipeline, Compositing and Layers.
The substitutions
Almost every layout-animating property has a compositor equivalent:
✗ left: 0 → 200px ✓ transform: translateX(200px)
✗ width: 100px → 300px ✓ transform: scaleX(3)
✗ height / top for a ✓ transform: translateY() on a wrapper,
slide-in or scaleY with a counter-scaled child
✗ display: none → block ✓ opacity + visibility, or transition
to an intrinsic size — see below
✗ background-color fade ✓ opacity on a stacked coloured layer
scale is not always a drop-in. Scaling text scales its rendering rather than reflowing it, so it blurs or thickens. For anything containing type, translate a wrapper rather than scaling it.
The height-auto problem — animating to height: auto has never worked, because the browser has no numeric end value to interpolate towards. The long-standing workarounds are max-height with a guessed value (janky and imprecise) or measuring and setting a pixel height in JavaScript. Modern CSS can interpolate to intrinsic sizes directly, which finally solves it. [CHECK: support for animating to intrinsic sizes varies — verify before relying on it, and keep a non-animated fallback.]
will-change, and why less is more
.card { will-change: transform; } /* promote to its own layer, in advance */Each promoted layer costs GPU memory, roughly width × height × 4 bytes. A full-screen layer on a 1440×900 device is ~5 MB, and a listing page that promotes forty cards can exhaust memory on a mid-range phone — at which point the browser starts evicting layers and everything gets slower, which is the opposite of the intent.
✓ apply will-change just before animating, remove after
✓ or on a small number of elements known to animate constantly
✗ will-change: transform on every card "to be safe"
✗ the old translateZ(0) hack, applied globally
Check the layer count in DevTools’ Rendering panel rather than guessing — DevTools.
Diagnosing it
1 Rendering panel → Paint flashing
green rectangles on every repaint. a whole panel flashing
during a transform animation means something in it is
repainting per frame
2 Rendering panel → Frame rendering stats
live FPS. watch it while you interact
3 Performance panel → record the interaction
look for purple LAYOUT blocks inside the animation window.
any at all means a layout-triggering property is animating
4 Layer borders
count them. too many is a memory problem, not a speed one
A layout block during an animation is the finding. It names the property, and the property names the fix.
The other half: not animating at all
prefers-reduced-motionis not optional. Vestibular disorders make large motion genuinely disabling. Honour it by removing the movement, not by slowing it down — Reduced Motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}- Durations of 150–300ms for interface feedback. Beyond ~400ms an animation stops reading as responsiveness and starts reading as a wait — Motion and Transitions
- Never animate on the critical path. An entrance animation on the hero delays the LCP element being visible, and LCP is measured on when it renders
- Scroll-linked animation is the most common source of jank. Prefer CSS scroll-driven animations, which run off the main thread, over a scroll listener that writes styles — and if you must listen, use a passive listener and write inside
requestAnimationFrame— requestAnimationFrame and Scheduling, Event Handling
Where it interacts
- Compositing and Layers — what promotion means and what it costs
- Reflow and Repaint — which property changes trigger which pipeline stage
- Interaction to Next Paint — the metric a janky animation degrades
- Cumulative Layout Shift — animating layout properties can register as shift, so a transform-based animation is better on both counts