What you'll learn
Quick Answer
Modern browsers split a page into compositor layers, rasterize each layer to a GPU texture once, and then let the GPU handle animating transform, opacity, and filter changes by cheaply re-transforming or re-blending those existing textures - skipping layout and paint entirely on every frame. That's the difference between a silky animation and a janky one: whether the browser can stay on the GPU compositor thread or has to re-run the full rendering pipeline on the main thread for every frame. At 60fps you get roughly 16ms per frame, and verified guidance puts about 10ms of that actually available to your code after browser overhead.
After paint comes compositing
The standard rendering pipeline — parse HTML and CSS into the DOM and CSSOM, build a render tree, run layout to compute geometry, and paint pixels — is the part most explanations of browser rendering stop at (we cover that stage in detail in our critical rendering path post). But paint doesn't put pixels directly on your screen. Paint produces a sequence of drawing instructions for one or more layers, and there's a final stage, compositing, that takes those layers and assembles the actual image you see.
The browser doesn't treat the whole page as one giant canvas it repaints from scratch every frame. It can split parts of the page into separate layers — think of them as independent, stacked image sheets — each of which gets rasterized (turned into an actual bitmap) once and handed to the GPU as a texture. A dedicated compositor thread then combines these textures into the final frame. Crucially, this compositor thread is separate from the JavaScript main thread, which is why some animations keep running smoothly even while JavaScript is busy doing something else on the main thread, and others visibly stall.
The properties that skip layout and paint entirely
Verified against web.dev's own guidance on this exact topic: transform, opacity, and filter are the properties the compositor can animate on its own, without re-running layout or paint at all. If an element is already sitting on its own compositor layer with an already-rasterized texture, changing its transform is just "draw this same texture, but moved/rotated/scaled," and changing its opacity is just "draw this same texture, but blended at a different alpha." Both are simple operations the GPU is extremely good at — a matrix multiply and an alpha blend — applied to a bitmap that was already computed. Nothing about the page's layout changed, and nothing needs to be re-painted pixel by pixel.
This is also why these properties are specifically called out as capable of running entirely on the compositor thread, independent of the main thread. If your JavaScript is busy running a long synchronous function, a transform-based CSS animation can keep ticking at a steady frame rate regardless, because the compositor doesn't need the main thread's attention to keep compositing already-rasterized layers.
The properties that trigger layout or paint
Contrast that with animating width, height, top/left (with positioned elements), or margin. These properties change an element's actual geometry, and geometry changes can cascade — a wider box can push its siblings, which can push their siblings, and so on. The browser has no choice but to re-run layout for the affected subtree (often more of the page than you'd expect), figure out the new geometry for everything that might have shifted, and then re-paint whatever changed. Doing that once per frame, sixty times a second, for a long-running animation is a lot of repeated, mostly-redundant work for something that's conceptually just "move this box."
There's a middle tier too: properties like background-color or box-shadow don't change geometry, so they can skip layout, but they do require re-rasterizing the affected area (paint), since the actual pixel content is different. That's cheaper than a full layout-plus-paint cycle, but still more expensive than the compositor-only path, and still runs on the main thread rather than being fully offloaded.
The practical upshot, and the reason "animate transform instead of top/left" is such common advice: for a moving element, transform: translateX(...) and top/left can produce the visually identical result, but one of them takes the cheap compositor-only path and the other forces layout and paint on every single frame of the animation.
What will-change actually does, and its real caveats
Promoting an element to its own compositor layer has a one-time setup cost — the browser has to allocate a texture and do the initial rasterization. If that promotion happens for the first time exactly when an animation starts, you can see a small stutter right at the start, because the cost lands on the first frame instead of being paid ahead of time.
will-change exists to let you tell the browser ahead of time, "I'm about to animate this property, go ahead and set up the layer now." Set will-change: transform on an element shortly before you start animating it, and the browser can do the promotion work during idle time instead of during the animation's first frame.
The verified caveat, straight from MDN's own guidance: will-change is meant as a targeted fix for an animation you've already identified as janky, not something to sprinkle everywhere pre-emptively. Every promoted layer is a separate GPU texture consuming GPU memory, and applying it broadly (to a large section of the page, or leaving it on indefinitely instead of toggling it off once the animation finishes) can create more compositor layers than the GPU can comfortably juggle, which paradoxically hurts the performance it was meant to help. The recommended pattern is: add it just before the animation starts, remove it shortly after the animation ends.
The actual frame-budget math
"Smooth" has a precise number behind it: 60 frames per second means 1000 milliseconds divided by 60, which comes out to roughly 16.67ms per frame — commonly rounded to 16ms in browser performance guidance. Every piece of work for that frame — JavaScript execution, style recalculation, layout, paint, and compositing — has to fit inside that window, or the frame is late and you get a visible stutter, commonly called jank.
It's not even the full 16ms available to your own code. Verified guidance from web.dev's RAIL performance model puts the browser's own rendering and compositing overhead at around 6ms of that budget, leaving roughly 10ms as the practical target for your application's own work per frame. That 10ms figure is the actual commonly-cited goal for animation code, specifically because it already accounts for the browser needing its own slice of every frame.
This is also why offloading work to the compositor thread matters so much: a transform-based animation that runs on the compositor isn't competing for that 10ms main-thread slice at all, so a busy main thread (doing data fetching, a big re-render, whatever) doesn't cause it to drop frames the way a layout-triggering animation would.
Putting it into practice
If you're building an animation and want it to run smoothly regardless of what else is happening on the page, the practical checklist looks like this:
- Animate
transformandopacitywherever the visual effect allows it — a slide-in can be atranslateX, a fade can be anopacitychange, a grow/shrink can be ascale. - Avoid animating
width,height,top,left, ormarginfor anything that runs continuously or needs to feel buttery; reserve them for changes that happen once and aren't performance-sensitive. - Use
will-changenarrowly and temporarily — apply it right before an animation starts, and remove it once the animation is done, rather than leaving it set permanently on elements "just in case." - If an animation still looks janky, profile it in the browser's performance panel before guessing — it will tell you directly whether frames are being lost to layout, paint, or script, rather than you assuming.
/* cheap: compositor-only, no layout or paint triggered */
.card--enter {
transform: translateY(0) scale(1);
opacity: 1;
transition: transform 200ms ease-out, opacity 200ms ease-out;
}
.card--enter-from {
transform: translateY(16px) scale(0.98);
opacity: 0;
}
/* expensive: triggers layout on every frame of the transition */
.card--enter-slow {
width: 100%;
margin-top: 0;
transition: width 200ms, margin-top 200ms;
}
