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
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.
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.
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.
Related Guides
- Animation Performance Patterns — the parent guide covering the full property cost model and the Web Animations API.
- Transform and Opacity Best Practices — the reference for which properties stay compositor-only.
- Why Transform and Opacity Are GPU-Accelerated — the layer and texture mechanism behind the offload.
- How to Batch DOM Reads and Writes to Prevent Thrashing — the read/write discipline that keeps FLIP’s single forced layout from multiplying.