Why top/left animations stutter but transform/opacity stay silky smooth
A look at the browser's rendering pipeline to explain why top/left animations tend to drop frames, and how pairing transform and opacity with will-change gets your animation onto the GPU for genuinely smooth CSS.
If you've written CSS animations before, you've probably run into this: you slide a box from left to right, and on mobile it stutters like a slideshow. Switch to a different approach for the exact same effect, and suddenly it's buttery smooth. The difference usually comes down to one thing — whether you moved the element with top / left, or with transform.
This post digs into what's actually happening under the hood: what steps does the browser go through to paint a single frame? Why are some CSS properties so much more expensive to animate than others? And what is will-change really doing for you — and where can it backfire?
Painting a frame is basically an assembly line
To understand why some animations stutter, you first need to know what happens every time the browser paints a frame. Think of it as an assembly line — data goes in one end, passes through a few stations, and comes out as pixels on your screen:
- Style: the browser figures out which CSS rules apply to each element and what the computed styles end up being.
- Layout (also called reflow): the browser calculates each element's size and position on the page — how wide, how tall, at what coordinates.
- Paint (also called repaint): each element gets rasterized into actual pixels — color, shadows, borders, all the visual detail.
- Composite: the painted layers get stacked in the right order and sent to the screen.
The key thing is that this pipeline is sequential — if an earlier stage has to redo its work, everything downstream usually has to redo its work too. Change a property that affects layout (say, width, top, or left), and the browser has to rerun Layout. Once Layout reruns, Paint and Composite get dragged along for the ride, whether they need to or not.
Smooth animation generally means hitting 60fps, which gives you about 16.7ms per frame. If every single frame has to run the full Layout → Paint → Composite pipeline, that budget disappears fast, and you start dropping frames — which is exactly what stuttering looks like.
Why top/left are so expensive
When you animate with top / left, the browser's reasoning is: this element's position changed, so does that mean the layout around it — or even the whole page — needs to be recalculated? So every time the value changes, it triggers Layout.
/* Layout gets recalculated on every single frame */
.box {
position: absolute;
transition:
top 0.3s,
left 0.3s;
}
.box.active {
top: 200px;
left: 300px;
}Even when the element is position: absolute and moving it wouldn't actually affect any other element's position, the browser plays it safe and triggers Layout and Paint anyway most of the time. Every frame of the animation means recalculating position, repainting pixels, and recompositing — all three steps, no skipping. The main thread ends up swamped, and the moment some other JavaScript task blocks it even briefly, the animation stutters.
transform and opacity: skipping the first two stations
What makes transform and opacity special is that changing them doesn't affect layout and doesn't require repainting any pixels. The element's shape and the space it occupies stay exactly the same — all that changes is where an already-painted layer gets placed, or how transparently it gets composited on top.
/* Only compositing needed — no reflow, no repaint */
.box {
transition:
transform 0.3s,
opacity 0.3s;
}
.box.active {
transform: translate(300px, 200px);
opacity: 0.5;
}That means the browser can skip Layout and Paint entirely and handle everything at the Composite stage. Even better, compositing can be handed off to the compositor thread, which taps into GPU hardware acceleration to move, scale, rotate, or fade an already-painted layer using pure matrix math. None of this depends on what the main thread is doing — so even while JavaScript is busy chewing through some expensive task, this kind of animation usually keeps running smoothly.
That's the real reason translate() beats top / left for the exact same "move a box" effect: one is a compositing job the GPU can knock out on its own, while the other forces the main thread to rerun the entire layout-and-paint pipeline.
will-change: giving the browser a heads-up
For an element to get GPU-accelerated compositing, the browser usually has to first promote it to its own layer, separate from everything else. That promotion itself takes time — and if it happens right as the animation kicks off, you can end up with a stutter on the very first frame.
That's the problem will-change solves. It's like giving the browser advance notice: "this element is about to change, so get it ready ahead of time." Once the browser sees the hint, it can promote the element to its own layer early, so there's no last-minute setup when the animation actually starts.
.box {
will-change: transform, opacity;
}It sounds like a silver bullet, but will-change isn't free — every extra layer costs memory and GPU resources. Slap will-change on a huge pile of elements without thinking, and the browser ends up juggling far too many layers; memory pressure goes up and performance actually gets worse.
A safer pattern is to add will-change right before the animation starts, then remove it once the animation is done:
const box = document.querySelector('.box')
box.addEventListener('mouseenter', () => {
box.style.willChange = 'transform' // hint the browser right before the animation starts
})
box.addEventListener('transitionend', () => {
box.style.willChange = 'auto' // reset once done, freeing up the layer
})One caveat:
will-changeis a hint, not a guarantee — the browser can still decide not to honor it based on available resources. And it's not a performance charm you sprinkle everywhere; reserve it for elements you know are about to change frequently.
A simple rule of thumb
When you're writing an animation, ask yourself one question: does this property's change affect the element's size or its position relative to other elements?
- Properties that affect layout —
width,height,top,left,margin— should generally be avoided for frame-by-frame animation. Swap intransformwherever you can. - Properties that only affect visual appearance without touching layout —
transform,opacity— are your best bet for animation performance.
Here's a common example: a card hover-to-scale effect. Instead of animating width / height:
/* Avoid: triggers Layout + Paint */
.card {
transition:
width 0.2s,
height 0.2s;
}
.card:hover {
width: 220px;
height: 220px;
}
/* Prefer: only triggers Composite */
.card {
transition: transform 0.2s;
}
.card:hover {
transform: scale(1.1);
}The visual result is nearly identical, but the second version never touches Layout or Paint — it's noticeably smoother.
Wrapping up
Whether a CSS animation stutters isn't about how much code you wrote — it's about whether the properties you're changing force the browser to rerun Layout and Paint. Layout-affecting properties like top / left pay the full cost of reflow, repaint, and composite on every single frame. transform and opacity skip the first two steps entirely and hand the work straight to the compositor thread and the GPU — that's the real reason they animate so smoothly. When you need to go a step further, use will-change to give the browser advance notice so it can prepare a layer ahead of time — just remember to remove it once you're done, or it becomes a new performance liability instead of a fix.