Animation Performance Patterns

Smooth animation comes down to one rule: do as little per-frame work as possible, and keep that work off the main thread. This topic covers the compositor-only properties (transform and opacity), the Web Animations API, requestAnimationFrame timing, how to avoid triggering layout or paint on every frame, and how to diagnose jank. This is part of Compositing and GPU Acceleration.

At 60Hz you have 16.6ms per frame, and the browser needs part of that for its own compositing work, so realistically an animation tick should finish in a few milliseconds. Animating a geometric property like left or width spends that budget on layout and paint every frame; animating transform spends almost nothing because the compositor reuses the existing GPU texture. The whole topic is choosing the cheap path and keeping it cheap.

Per-frame cost of animating left versus transform Animating left runs style, layout, paint and composite every frame and exceeds the budget. Animating transform runs only composite and stays well within the 16.6ms frame budget. animate: left (24ms / frame) animate: transform (3ms / frame) Style Layout Paint Composite Composite 16.6ms budget

Compositor-Only Properties

Only two CSS properties can be animated entirely on the compositor thread: transform and opacity. Once an element is on its own layer, the compositor applies a transform by multiplying the layer’s draw matrix and applies opacity by changing a blend factor β€” neither requires re-running style, layout, or paint. Every other property (left, top, width, height, margin, box-shadow, background) needs at least a repaint and usually a full layout before a frame can be drawn. The reasons are detailed in Transform and Opacity Best Practices and why transform and opacity are GPU-accelerated.

// ❌ Animating width re-runs layout + paint every frame (~24ms on mid-tier)
function grow(el, p) { el.style.width = `${100 + p * 200}px` } // forces layout per frame

// βœ… Animating transform stays on the compositor (~1ms, main thread free)
function grow(el, p) { el.style.transform = `scaleX(${1 + p * 2})` } // compositor-only

The full migration from top/left/width/height to transforms, including the FLIP technique and will-change promotion, is covered in animating transforms without layout thrash.

The Web Animations API vs requestAnimationFrame

There are two ways to drive an animation. A requestAnimationFrame loop runs your JS callback before every frame β€” fine for physics or canvas, but the callback executes on the main thread, so a busy main thread janks it. The Web Animations API (element.animate(...)) and CSS transitions/animations, when limited to transform/opacity, are handed to the compositor and run off the main thread β€” they keep going even during a long task.

// βœ… Web Animations API on compositor-only props: survives main-thread jank
el.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(240px)' }],
  { duration: 400, easing: 'ease-out' },
) // compositor runs this without per-frame JS

// requestAnimationFrame loop: runs on the main thread, blocked by long tasks
function tick(now) {
  el.style.transform = `translateX(${progress(now)}px)`
  requestAnimationFrame(tick)
}

Reserve requestAnimationFrame for animations that genuinely need per-frame JS (and use it to batch DOM writes, never to read geometry mid-frame β€” that path is covered in debouncing scroll-driven layout reads).

Avoiding Layout and Paint Per Frame

The diagnostic question for any animation is: which pipeline stages run each frame? A compositor-only animation shows only Composite Layers. Any Layout, Recalculate Style, or Paint entry recurring per frame means the animation is touching a non-compositor property β€” or an ancestor’s geometry is reacting to the animated element.

Property animated Stages per frame Typical cost Budget risk
transform, opacity Composite < 1ms Low
top / left Style β†’ Layout β†’ Paint β†’ Composite 12–24ms High
width / height Style β†’ Layout β†’ Paint β†’ Composite 14–28ms High
box-shadow / filter: blur Style β†’ Paint β†’ Composite 6–18ms Medium
background-color Style β†’ Paint β†’ Composite 3–8ms Medium

Jank Diagnosis

[Frame] Budget: 16.6ms | Actual: 23.7ms β€” DROPPED
└─ Main thread
    β”œβ”€ Recalculate Style (2.0ms)
    β”œβ”€ Layout (11.4ms)     ← animating 'left' forces this every frame
    β”œβ”€ Paint (5.1ms)
    └─ Composite Layers (3.8ms)
[Frame] Budget: 16.6ms | Actual: 3.9ms β€” OK
└─ Compositor thread
    └─ Composite Layers (3.9ms)   ← same motion, animated via transform

The two frames produce the same on-screen motion; the first does it with layout and paint on the main thread, the second with a single compositor pass. To find these frames, record in the Performance panel and look for repeating Layout/Paint bars locked to the animation’s duration. Framework-driven re-renders add another layer of cost β€” how React’s concurrent rendering interacts with forced reflow is covered in react concurrent rendering vs forced reflow.

Metric Validation

// Flag frames that miss the budget during an animation
let last = performance.now()
function watch() {
  const now = performance.now()
  if (now - last > 18) console.warn(`Frame ${(now - last).toFixed(1)}ms`)
  last = now
  requestAnimationFrame(watch)
}
requestAnimationFrame(watch)

// Long Animation Frames attribute jank to a script or layout source
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) console.log('LoAF', e.duration, e.scripts)
}).observe({ type: 'long-animation-frame', buffered: true })
Metric Target How measured
Layout/Paint per animation frame 0 Performance panel
Frame interval during animation ≀ 16.6ms rAF delta / Frame track
Long Animation Frames none > 50ms long-animation-frame observer
INP during interactive animation < 200ms Event Timing API

For the source-level attribution of janky frames see tracking long animation frames, and continue to animating transforms without layout thrash and react concurrent rendering vs forced reflow for the focused techniques.