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.