Tags: web-dev concept

Animation Performance

Date: 2026-08-17


Two properties animate without touching layout or paint — transform and opacity — 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-motion is 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