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.

How the compositor applies transform and opacity A texture painted once is reused across frames; the compositor only multiplies a matrix for transform and changes a blend factor for opacity, with no repaint. Painted texture rasterized once Compositor thread transform → matrix multiply opacity → blend factor Frame N Frame N+1 (no repaint) Frame N+2 (no repaint)

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

A long task stalls rAF but not compositor animations A long task on the main thread freezes requestAnimationFrame ticks, while a Web Animations API animation on the compositor keeps advancing through the same window. Main thread rAF loop Compositor WAAPI tick tick Long task 220ms — rAF frozen tick tick tick tick tick tick tick tick tick tick tick Compositor keeps advancing through the long task

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
Which pipeline stages a property change triggers A decision tree mapping the animated property to the stages that run each frame: transform and opacity composite only, paint properties add paint, and geometry properties add layout and paint. Property changed? transform / opacity background / box-shadow left / top / width / height Composite only < 1ms per frame Style → Paint → Composite Style → Layout → Paint → Composite

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.

Dropped frame versus compositor-only frame against the budget A stacked bar for the dropped frame overruns the 16.6ms budget line across style, layout, paint and composite, while the compositor-only frame is a single short composite segment well under budget. Dropped 22.3ms OK 3.9ms Style Layout 11.4ms Paint Composite Composite 16.6ms budget

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
The four animation-health metrics and their pass thresholds Four rows pairing a metric with its target and the tool that reads it: layout and paint per frame at zero, frame interval at or under 16.6ms, no long animation frames over 50ms, and INP under 200ms. Metric Target Read with Layout / Paint per frame 0 Performance panel Frame interval ≤ 16.6 ms rAF delta Long Animation Frames none > 50 ms LoAF observer INP (interactive) < 200 ms 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.

Driving Animation Off the Main Thread

The core principle of smooth animation is that the browser, not your JavaScript, should drive the frames. When you hand keyframes to the engine β€” via a CSS animation/transition or the Web Animations API β€” and they only touch transform and opacity, the compositor samples them on its own thread every vsync, independent of whatever the main thread is doing. A 180ms long task on the main thread cannot stall such an animation, because no per-frame callback of yours sits on the critical path. Contrast this with a requestAnimationFrame loop that writes element.style.transform each tick: even though it animates a compositor property, the driver is on the main thread, so the same long task that blocks script also freezes the animation. Registering the animation instead of driving it is the difference, and it is the pattern behind animating transforms without layout thrash.

The Web Animations API makes this explicit: element.animate(keyframes, options) registers the animation once and returns an Animation object that still gives you finished, cancel(), pause(), and playbackRate for control, all without a per-frame callback. For genuinely per-frame JavaScript work β€” a canvas particle system, a physics simulation β€” the equivalent move is to push the computation off the main thread with a Web Worker and OffscreenCanvas, or at minimum to break the blocking task with scheduler.yield() so animation frames can interleave. The rule generalises: keep the thing that advances the animation off the thread that also runs your application logic.

// ❌ compositor property, but driven on the main thread β€” a long task freezes it
function tick(now) {
  card.style.transform = `translateX(${progress(now) * 240}px)`
  requestAnimationFrame(tick)
}
// βœ… registered once, sampled by the compositor β€” immune to main-thread jank
card.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(240px)' }],
  { duration: 400, easing: 'ease-out' },
)

FLIP and Animating Layout Changes

The hardest animations are the ones that are inherently about layout β€” an item moving to a new position in a reordered list, a card expanding from a thumbnail. Animating the layout properties directly would force layout every frame, so the technique is FLIP: First, Last, Invert, Play. Measure the element’s start position (First), apply the end state and measure again (Last), compute the delta and apply an inverse transform so it appears not to have moved (Invert), then animate the transform back to zero (Play). The net effect is a layout change that animates entirely on the compositor, because the only thing changing per frame is a transform. The two layout reads happen once, up front, not every frame.

FLIP is powerful precisely because it converts the one class of animation that seems to require per-frame layout into a compositor-only one. The catch is the measurement step: the getBoundingClientRect() reads must be batched and must not interleave with writes, or you reintroduce the forced reflow you were trying to avoid β€” the same discipline from forced synchronous layouts. In component frameworks the reads belong in the pre-paint phase (useLayoutEffect in React, before nextTick in Vue), and the concurrency implications are covered in React concurrent rendering vs forced reflow. Done correctly, FLIP delivers list-reordering and shared-element transitions at full frame rate on hardware that would choke on the naive layout animation.

Diagnosing Animation Jank

When an animation stutters, the trace tells you which of the two failure modes you have. Record a Performance profile during the animation and read the two tracks: if Composite Layers runs on the Compositor track with the Main track idle, the animation is on the fast path and any jank is coming from elsewhere. If instead you see Recalculate Style, Layout, or Paint firing on the Main track every frame, the animation itself is the problem β€” either it animates a non-compositor property, or its driver is a main-thread requestAnimationFrame loop. Paint flashing distinguishes the two: green rectangles every frame mean a repaint, so a non-compositable property is in play; no green but per-frame Main-track work means the driver is on the wrong thread.

The fix follows directly from the diagnosis: a repainting property becomes transform/opacity; a main-thread driver becomes a registered WAAPI or CSS animation; a layout animation becomes FLIP; and unavoidable per-frame computation moves to a worker. The one anti-pattern to retire is reaching for will-change as a first response to jank β€” it promotes a layer, which can help a genuine compositor animation but does nothing for a forced reflow or an expensive paint, and left permanently it just costs memory. Diagnose first, then apply the specific fix, and verify in the same trace that the Main track went quiet.

Honouring Reduced Motion

Performance and accessibility meet at prefers-reduced-motion. A compositor animation is cheap, but for users who experience motion sickness it can still be harmful, so non-essential motion belongs inside @media (prefers-reduced-motion: reduce) where it is shortened, replaced with a simple fade, or removed. This is free on the performance axis because it removes frames rather than adding them β€” an animation you do not run is the cheapest one there is β€” and for the meaningful fraction of users who set the preference, honouring it makes the experience strictly faster as well as more comfortable. Author the full animation as the enhancement and the reduced-motion branch as the safe default, and both goals are served at once.

The same reduced-motion query is a useful escape hatch for expensive-but-decorative effects that resist the compositor fast path. If a particular flourish genuinely needs a property that forces paint, gating it behind the default-on animation branch means it never runs for reduced-motion users and can be kept modest for everyone else. Treating motion as opt-in enhancement rather than baseline keeps both the frame budget and the accessibility contract intact, which is the posture this whole section argues for: spend animation budget deliberately, and never spend it where the user has asked you not to. In a component library, reading the preference once and exposing it as a token that every animated component consumes makes the contract consistent across the app rather than something each author has to remember to honour.

The One Rule Behind Every Pattern

Every technique in this section is an application of a single rule: keep the thing that advances an animation off the main thread, and keep the properties it changes to transform and opacity. Register animations with CSS or the Web Animations API rather than driving them from requestAnimationFrame; convert layout animations to FLIP so only a transform changes per frame; push unavoidable per-frame computation to a worker; and honour reduced-motion so the cheapest animation β€” the one that does not run β€” is available to those who want it. When an animation janks, the diagnosis always comes back to a violation of that rule, and the fix always restores it.

Frequently Asked Questions

Why does animating transform stay smooth when animating left drops frames?

transform and opacity are applied by the compositor on an already-painted layer texture β€” the compositor multiplies a draw matrix or changes a blend factor without re-running style, layout, or paint. Animating left changes geometry, so the browser must run Recalculate Style, Layout, and Paint on the main thread every frame before it can composite, which blows the ~16.6ms budget. See Transform and Opacity Best Practices.

Does the Web Animations API keep running during a long task?

Yes, if the animation is limited to transform and opacity. element.animate(...) on those properties is handed to the compositor thread, so it advances even while the main thread is blocked by a long task. A requestAnimationFrame loop, by contrast, runs its callback on the main thread and freezes until the task finishes.

How do I tell from a DevTools recording which property is causing jank?

Record the animation in the Performance panel and look at the stages that repeat once per frame. A compositor-only animation shows only Composite Layers. If you see Recalculate Style, Layout, or Paint bars locked to the animation’s duration, a non-compositor property is being animated, or an ancestor’s geometry is reacting to the animated element.

When is requestAnimationFrame still the right tool?

Use requestAnimationFrame when you genuinely need per-frame JavaScript β€” physics simulations, canvas rendering, or synchronising values you cannot express as a CSS keyframe. Use it to batch DOM writes into the frame, and never read layout geometry inside the callback, which forces a synchronous layout flush mid-frame.

What is a Long Animation Frame and how does it help?

A Long Animation Frame (LoAF) is any frame that takes longer than 50ms, surfaced through the long-animation-frame PerformanceObserver entry. It attributes the delay to a specific script or layout source, which turns β€œthe page feels janky” into a concrete culprit. See tracking long animation frames.