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.

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.

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

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.

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

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.