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.
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.