Animating Transforms Without Layout Thrash

Animating top, left, width, or height forces the browser to run layout and paint on every frame; animating transform instead keeps the motion on the compositor thread at under 1ms per frame. This builds on Animation Performance Patterns, part of Compositing and GPU Acceleration.

Why Geometric Properties Thrash

top, left, width, and height are layout inputs. Changing one invalidates the element’s box and usually its siblings’ and ancestors’ boxes too, so the browser re-runs layout, repaints the affected region, then composites — the whole pipeline, every frame. transform, by contrast, is applied after layout: the element’s box is already computed and rasterized into a GPU texture, and the compositor just multiplies that texture by a new matrix. No layout, no paint, no main-thread work. The reasons this offload is possible are detailed in why transform and opacity are GPU-accelerated.

Two properties, two paths through the pipeline Animating left runs Style, Layout, Paint and Composite every frame, while animating transform runs Style then jumps straight to Composite. Same motion, two paths through the pipeline Geometric property — full pipeline every frame left / top Style Layout Paint Composite transform — compositor only transform Style Layout skipped Paint skipped Composite GPU texture reused — matrix multiply only Top path costs ~20ms/frame; bottom path ~3ms/frame on the compositor thread.

Minimal Reproduction

// ❌ Animating left re-runs layout + paint on every animation frame
const el = document.querySelector('.card')
let x = 0
function slide() {
  el.style.left = `${x}px` // layout input → forces layout + paint each frame
  x += 4
  if (x < 400) requestAnimationFrame(slide)
}
slide()

On a mid-tier phone this drops frames immediately: each left write costs 12–20ms because the surrounding flow re-flows.

The Trace Signature

[Frame] Budget: 16.6ms | Actual: 22.8ms — DROPPED
└─ Main thread
    ├─ Recalculate Style (1.8ms)
    ├─ Layout (12.6ms)   ← 'left' invalidated the box and its siblings
    ├─ Paint (5.0ms)
    └─ Composite Layers (3.4ms)

Repeating Layout + Paint bars locked to the animation duration are the layout-thrash signature.

Frame time against the 16.6ms budget The left animation frame overruns the budget at 22.8ms while the transform frame finishes well under it at 3.6ms. Frame time vs the 16.6ms budget 16.6ms budget left / top 22.8ms — dropped transform 3.6ms 0ms

The Fix: Animate transform

Express the same motion as a translate. Because the element keeps its original box, nothing re-flows.

// ✅ Same motion via transform — compositor-only, no layout or paint
const el = document.querySelector('.card')
el.style.willChange = 'transform' // hint: rasterize on its own layer ahead of time
el.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(400px)' }],
  { duration: 400, easing: 'ease-out', fill: 'forwards' },
)
// release the hint when the animation ends so the GPU texture is freed
el.getAnimations()[0].finished.then(() => { el.style.willChange = 'auto' })

The trace collapses to a single Composite Layers entry per frame, and it keeps running even if the main thread is busy.

[Frame] Budget: 16.6ms | Actual: 3.6ms — OK
└─ Compositor thread
    └─ Composite Layers (3.6ms)   ← no Style / Layout / Paint

Because the animation lives on its own layer, it keeps ticking on the compositor thread even while the main thread is blocked by a long task — the jank on one thread never stalls the other.

Compositor thread runs independently of the main thread A long task blocks the main thread while the compositor thread keeps producing composite frames for the transform animation. transform animates on the compositor thread Main thread Long Task — JS (180ms), blocked Comp. thread Composite Composite Composite Composite Composite Composite translateX keeps 60fps here — one composite per frame, unaffected by main-thread jank.

will-change Promotion, Used Sparingly

will-change: transform tells the browser to promote the element to its own layer and rasterize it in advance, so the first animated frame doesn’t pay a synchronous rasterization cost. But every promoted layer costs GPU memory, so set it only just before the animation and reset it to auto afterward (as above). Leaving will-change on dozens of static elements exhausts VRAM and triggers texture eviction.

FLIP: Animating Layout Changes With Transforms

When the layout genuinely changes — an item moves to a new grid position, a list reorders — you still want the motion to be transform-only. The FLIP technique (First, Last, Invert, Play) measures the start and end boxes, then animates the delta with a transform so the browser never animates a layout property.

// ✅ FLIP: animate a real layout change using only transform
function flip(el, mutate) {
  const first = el.getBoundingClientRect()       // First: measure start box
  mutate()                                        // apply the DOM/layout change
  const last = el.getBoundingClientRect()         // Last: measure end box (one forced layout, once)
  const dx = first.left - last.left               // Invert: delta to undo the jump
  const dy = first.top - last.top
  el.animate(                                     // Play: transform back to zero
    [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }],
    { duration: 300, easing: 'ease-in-out' },
  )
}

FLIP pays exactly one forced layout (the Last measurement) instead of one per frame — the read/write discipline behind that is covered in how to batch DOM reads and writes to prevent thrashing.

The four steps of FLIP First measures the start box, Last mutates and measures the end box, Invert applies the delta as a transform, and Play animates it back to zero. FLIP: four steps, one transform First measure start box Last mutate DOM, then measure end box Invert translate by delta Play animate back to 0 One forced layout total (at Last) — zero layout per frame during Play.

Verification

// Confirm no layout/paint recurs during the animation window
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) {
    if (e.duration > 50) console.warn(`Long animation frame ${e.duration}ms`, e.scripts)
  }
}).observe({ type: 'long-animation-frame', buffered: true })
Check Target
Layout events during animation 0 per frame (1 total for FLIP)
Paint events during animation 0 per frame
Frame interval ≤ 16.6ms sustained
will-change left on idle elements 0
Decision tree for a compositor-safe animation Branch on the animated property: transform and opacity are composite-only, geometric properties are rewritten as transform or animated with FLIP. Is this animation compositor-safe? Which property changes? transform / opacity geometry: top / left / size Composite only — safe Rewrite as transform Use FLIP no real move real move

A passing trace shows only Composite Layers per frame. For the broader property cost model and the Web Animations API see Animation Performance Patterns, and for the safe-property reference see Transform and Opacity Best Practices.

Frequently Asked Questions

Why does animating left cause layout but transform does not?

left is a layout input: changing it invalidates the element’s box geometry, so the browser must re-run layout for that element and often its siblings and ancestors, then repaint. transform is applied after layout as a matrix on an already-rasterized GPU texture, so it skips layout and paint entirely and only re-composites. See why transform and opacity are GPU-accelerated for the mechanism.

Should I leave will-change: transform on my animated elements?

No. will-change: transform promotes the element to its own compositor layer and holds a GPU texture for it, which costs VRAM. Set it just before the animation starts and reset it to auto when the animation finishes. Leaving it on many idle elements exhausts GPU memory and triggers texture eviction, which can make animations slower rather than faster.

What is FLIP and when do I need it?

FLIP (First, Last, Invert, Play) animates a real layout change using only transform. You measure the start box, mutate the DOM, measure the end box, apply a transform equal to the delta, then animate that transform back to zero. Use it when the final position genuinely changes — a reordered list, a moved grid item — because you cannot express that end state with a static transform alone. It pays one forced layout total instead of one per frame.

How do I confirm an animation is not thrashing in a trace?

Record a performance trace over the animation window and look at the per-frame bars. A compositor-only animation shows a single Composite Layers entry per frame with no Recalculate Style, Layout, or Paint. Repeating Layout plus Paint bars locked to the animation duration are the thrash signature. A long-animation-frame PerformanceObserver flags frames that overran the budget programmatically.

Does transform work for size changes like growing a card?

Yes — animate transform: scale() instead of width/height. Scale runs on the compositor with no layout, though it also scales the element’s rasterized contents, which can blur text mid-animation. For crisp text, pair the scale with a FLIP-style measurement or re-rasterize at the final size once the animation settles.