Layout, paint, composite: why transform is cheap
Every visual change you make to the DOM runs through a three-stage pipeline, but not every property pays for all three stages. This lesson explains layout, paint, and composite as the browser actually executes them, and why animating transform or opacity can skip two-thirds of that work while animating top or left never can.
Layout, paint, composite: why transform is cheap
You've been told, probably more than once, "animate transform, not top and left." Most people who repeat that advice have never had to justify it beyond "it's smoother, trust me." That's a shame, because the actual reason is mechanical, not folklore, and once you see it you'll never need to memorize the rule again — you'll be able to derive it for any CSS property someone hands you.
Every frame the browser draws goes through the same three-stage pipeline: layout, paint, composite. The one governing insight for this whole lesson is that these three stages are not equally expensive, and — critically — not every change to the page needs to run all three. Some properties force the browser to redo the whole pipeline from the top, every single frame. Others let the browser skip straight to the cheap, GPU-friendly last stage. The property you choose decides which of those two worlds you're in.
Here's the map. We'll walk the three stages in order, work out why geometry-changing properties can never skip any of them, then work out why transform and opacity specifically get to skip the first two, and close with will-change — a way to pay part of that cost early, on your terms, instead of mid-animation.
The three stages, in the order the browser actually runs them
Layout is the browser answering, for every element on the page, "exactly how big is this box, and exactly where does it sit?" It has to account for the box model, flexbox and grid rules, text wrapping, and every ancestor and sibling that could push this element around. That last part is what makes layout expensive: it is not a per-element computation you can do in isolation. If one box grows by 20 pixels, every box after it in the flow may need to shift, and any box whose size depends on that box (a flex sibling, a parent using height: auto) may need to be recomputed too. Layout is inherently a whole-subtree operation — the browser can sometimes limit the damage to a smaller region, but in the worst case, and often in practice, a single geometry change ripples outward and the browser ends up re-measuring a large chunk of the page.
Paint happens once layout has settled and the browser knows where everything goes. Paint is the browser actually filling in pixels — text glyphs, background colors, box shadows, borders, images — onto one or more surfaces, usually called layers. Paint doesn't yet care how those layers relate to each other on screen; it's purely "what does this element look like," rendered into a bitmap.
Composite is the final step: take all the painted layers and assemble them into the single image that actually appears on the screen, in the right stacking order, at the right positions. This is the cheapest of the three stages by a wide margin, because on modern browsers it's handled by the GPU, which is extraordinarily good at exactly one thing — taking pre-rendered bitmaps and moving, scaling, rotating, or blending them. The GPU doesn't know or care what a <div> is; it just has textures to place.
The pipeline always runs in this order, and — this is the part worth burning in — a later stage can never run without the earlier ones having produced its input first. You cannot composite a layer that hasn't been painted. You cannot paint an element whose size and position you haven't computed. That dependency chain is the entire reason some properties are expensive and others aren't: it's not about which property is arbitrarily "blessed" as fast, it's about which stage of the pipeline that property's effect actually originates in.
Why geometry properties can never skip layout
Change top, left, width, height, or margin on an element, and the browser has no way to know the new answer to "where is this box, and how big is it" without literally recomputing it — there's no cached shortcut, because you just invalidated the input to that computation. And because layout answers are a precondition for paint (you can't paint pixels at a position you haven't determined) and paint is a precondition for composite, a layout-triggering change forces the entire pipeline to re-run, top to bottom, every time the property changes.
This is why animating left in a requestAnimationFrame loop, changing it by a pixel or two per frame, is so much heavier than it looks in the code. Sixty times a second, you're asking the browser to: recompute geometry for the element and everything downstream of it in the flow, re-rasterize the affected pixels, and then reassemble the final image. None of those three stages is free, and you're paying for all three, every frame, for the life of the animation.
Why transform and opacity get to skip straight to composite
transform (translate, scale, rotate, skew) and opacity are special for one reason: neither of them changes an element's geometry or its own appearance in a way that requires re-measuring or re-drawing it. Consider transform: translateX(100px). The element's actual size and its position in the normal flow — the thing layout computes — are completely untouched. Nothing about the element's content, color, or shape has changed either, so there's nothing new to paint. All that's changed is "where, on the final assembled image, does this already-painted bitmap get placed." That's purely a composite-stage question, and the GPU answers it by repositioning a texture it already has in memory — no re-measurement, no re-rasterization.
opacity is the same story: fading a layer from 1 to 0.5 doesn't change what was painted, it changes how that already-painted bitmap gets blended with what's underneath it during composite. Again, purely a last-stage operation.
This is why the standard advice isn't superstition, it's a direct consequence of the dependency chain from the previous section: layout and paint only need to re-run when their own inputs change, and transform/opacity simply don't touch those inputs. Once the browser has painted the element the first time, animating transform or opacity on every subsequent frame touches composite only — nothing upstream needs to be redone, because nothing upstream is stale.
Here's the same visual motion implemented both ways, so you have real code to compare rather than just the claim:
/* Expensive: every frame re-triggers layout + paint + composite */
.slide-left {
position: absolute;
left: 0;
transition: left 0.3s ease-out;
}
.slide-left.moved {
left: 200px;
}/* Cheap: every frame after the first touches composite only */
.slide-transform {
position: absolute;
transform: translateX(0);
transition: transform 0.3s ease-out;
}
.slide-transform.moved {
transform: translateX(200px);
}// Same trigger, same visual result, two very different pipelines underneath
el.classList.add('moved');Both rules end with the box 200px further right, animated over the same duration with the same easing — visually identical. The left version forces the browser to ask "where is everything now?" on every intermediate frame of that transition, because left is a genuine input to layout. The transform version never asks that question again after the first paint: the box's layout position stays left: 0 for the entire animation, and only the compositor's placement of the already-painted bitmap changes, frame by frame, entirely on the GPU.
The contrast, stated plainly
| Property changed | Layout? | Paint? | Composite? | Why |
|---|---|---|---|---|
top, left, width, height, margin, font-size | Yes | Yes | Yes | The property is itself an input to geometry — the browser must re-measure before it can re-draw or reassemble. |
background-color, box-shadow, border-color, color | No | Yes | Yes | Geometry is untouched, but the element's actual pixels changed, so paint must re-run before composite can use the new bitmap. |
transform, opacity | No | No | Yes | Neither geometry nor the painted bitmap changes — only how the existing bitmap is placed or blended during the final assembly. |
That middle row matters: it's not a strict binary between "everything reruns" and "only composite reruns." A huge number of common style changes (colors, shadows, borders) skip layout but still cost you a paint. transform and opacity are genuinely the only two properties in wide practical use that skip both of the expensive stages.
will-change: paying the promotion cost on your schedule
To composite an element independently — to give the GPU a standalone texture it can move around without re-painting anything else — the browser has to first promote that element to its own compositor layer. That promotion itself has a small one-time cost: allocating the memory for the layer, doing the initial paint of it in isolation.
If you don't hint the browser in advance, that promotion cost lands on the first frame of your animation, right when you least want a hiccup. will-change: transform tells the browser "this element is about to be animated via transform, go ahead and promote it to its own layer now, before the animation starts" — so the cost is paid ahead of time, during idle work, rather than showing up as a dropped first frame.
Where this goes next
Everything in this lesson treated layout as something the browser runs once per frame, on its own schedule. That assumption holds only if you let the browser batch its reads and writes — read a layout property (like offsetHeight) in the wrong place in your own code, interleaved with writes, and you can force layout to run far more often than once per frame. Batching DOM writes with requestAnimationFrame and Layout thrashing: the forced synchronous layout trap both pick up exactly that thread — the pipeline you now understand in isolation, running under conditions where "once per frame" stops being guaranteed.
Go deeper
- web.dev — Jank busting for better rendering performance — Lays out the layout → paint → composite pipeline this whole lesson is built on, with the browser's actual per-frame budget.
- web.dev — Stick to Compositor-Only Properties and Manage Layer Count — The authoritative case for transform/opacity specifically, plus the will-change layer-count warning this lesson raises.
- CSS Triggers — A property-by-property reference of exactly which of layout, paint, and composite each CSS property triggers — the lookup table for every property not covered here.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why is layout inherently a whole-subtree computation rather than something the browser can do for one element in isolation?
- A teammate changes an element's background-color in a loop and says 'it's fine, background-color doesn't touch layout.' What's missing from that reasoning?
- Walk through, stage by stage, why transform: translateX() never re-triggers layout on any frame after the first, while animating left re-triggers it on every frame.
- What does 'promoting an element to its own compositor layer' actually mean, and what one-time cost does will-change let you avoid paying mid-animation?
- Why is applying will-change: transform to every element on a page potentially a performance regression rather than a free win?
- If composite is so much cheaper than layout and paint, why doesn't the browser just composite everything and skip the other two stages entirely?
- You need to animate a modal sliding in from off-screen. Name the specific CSS property you'd animate and explain, in terms of the three stages, why your choice avoids the two expensive ones.