← back

The Box That Didn't Move

“Animate transform and opacity, not top or margin.” Everyone repeats it. I’ve repeated it in code review, and when someone asked me why, the best I had was “it’s faster”. Which is true, and not an answer.

Both of these boxes move a hundred pixels down. They look the same. One of them is a lot more work for the browser. Why?

The left box moves by changing margin-top on every frame, the right one by changing transform. At full speed you can’t tell them apart, so the demo runs at six frames a second, with the browser’s work lit up under each box as it goes:

margin-topframe 0
STYLE
LAYOUT
PAINT
COMPOSITE
transformframe 0
STYLE
LAYOUT
PAINT
COMPOSITE
0 / 24 frames
Slowed to six frames a second. Every frame, the four words light up for the work the browser does that frame. The left box lights all four; the right box lights one.

The four words under the boxes are the four stages the browser goes through to put a page on screen, and the left box lights up all of them, every frame. The right one barely lights up at all. They’re clearly not doing the same job.

To see why that matters, it helps to start from what a screen is.

Sixteen milliseconds

A screen is a grid of pixels that gets redrawn about sixty times a second. Every time anything on the page changes, the browser has to come up with the next grid of pixels before the next redraw. That’s a frame, and at sixty a second you get about sixteen milliseconds to make one. Miss it, and the screen shows the old picture again. Miss a few in a row, and that’s the stutter you can see.

So the question “how much work is this animation” really means “how much of that sixteen milliseconds does one frame of it eat”. And to know that, you have to know what the browser does to get from your HTML and CSS to a grid of pixels.

From a tree to pixels

The browser holds your page as a tree of elements. To draw it, it goes through the same stages every time.

First it works out which CSS rules apply to each element. That’s style.

Then it works out where everything goes and how big it is. That’s layout, and it’s the expensive one, because sizes depend on each other. A paragraph’s height depends on its width, its width depends on its parent, and everything below it moves if it grows. Change one box’s geometry and the browser can’t know what else moved without checking the neighbours, and their neighbours.

Then it draws. It records what to draw for each element (fill this rectangle, put this text here, in this font), and turns those instructions into pixels. That’s paint.

Now, you might expect paint to produce one big picture of the page. It doesn’t, and the reason is where the cost comes from. If the page were one picture, moving anything would mean repainting everything around it too. So the browser paints the page in pieces it can move without touching the others. Those pieces are layers. Think of sheets of transparent film stacked on a desk: you can slide one sheet, or fade it out, and the sheets under it stay exactly as they were.

The page drawn that way, with a button that does what the browser does when it decides an element needs a sheet of its own:

the page, drawn as sheets1 sheet
the page's sheetthe box, painted onto it
One sheet. The box is painted onto the same sheet as everything around it, which is the default. Moving it means repainting the sheet.

Most of a page lives on one sheet. The browser only gives an element a sheet of its own when it has a reason to (it’s animating, it’s a video, you’ve told it you’re about to animate it), because every extra sheet costs memory. Hold that thought, it comes back.

The last stage is to stack the sheets in order and hand the result to the screen. That’s composite. The part of the browser that does it is called the compositor, and it has one property that matters more than anything else here: it runs on its own thread. Style, layout and paint all run on the main thread, the same one as your JavaScript. The compositor doesn’t. It can keep sliding sheets around while the main thread is busy.

So, four stages: style, layout, paint, composite. Chromium’s own architecture doc splits it into twelve, but four is a fair compression, and it’s the same four web.dev uses.

Cheap means late

A change enters the pipeline at one stage, and every stage after it has to run again.

Change a width and you’ve entered at layout, so layout, paint and composite all rerun. Change a colour and nothing moved, so you’ve entered at paint, and only paint and composite rerun. And transform? A transform doesn’t change where the element is in the layout. The box is still exactly where it was, as far as layout is concerned. The transform gets applied to the box’s sheet at the very end, when the sheets are being stacked. So a transform change enters at composite, and style, layout and paint don’t get woken up at all. Chromium’s doc says this in one line: animations of visual effects (transform, opacity, filter) can skip layout, pre-paint and paint.

opacity is cheap for the same reason. Fading a sheet is something you do while stacking sheets.

That’s the whole rule. transform isn’t a fast property. Its changes land late, and everything before the stage you land in gets to stay asleep.

And now the two boxes make sense. margin-top is geometry, so the left box drags the browser through layout and paint on every single frame, sixty times a second, on the main thread. The right box is a sheet being slid by the compositor, on its own thread, while the main thread does whatever else it was doing. That’s also why the right kind of animation stays smooth when your page is busy, and the wrong kind stutters the moment your JavaScript has work to do: one of them is waiting in the same queue as your code, and the other one isn’t.

Where each property lands

You used to look this up on csstriggers.com. That project was archived in 2022, and its data was older than that. Here’s the short table for Chromium today:

Enters atProperties
Layoutmargin, padding, top, left, width, height, font-size, border-width
Paintcolor, background-color, box-shadow, border-radius, outline
Compositetransform, opacity

filter and backdrop-filter also animate on the compositor in Chromium. I’m going by Blink’s list of compositing reasons, which is the list of reasons the browser will give an element a sheet of its own. It has an “active animation” entry for transform, opacity, filter and backdrop-filter, and for nothing else. When I want the real answer for a property now, that file is where I look.

When transform is still slow

You might think that settles it: use transform, done. Three things can still bite you, and the first one gets everyone once. All three come back to the sheets.

There’s no sheet yet

The compositor slides sheets, not elements. If the box you’re animating was painted onto the same sheet as everything around it (which, remember, is the default), then the moment your animation starts the browser realises it needs that box on a sheet of its own. Starting a transform animation is one of the reasons in that Blink list. But making a new sheet is main-thread work: go back through paint, split the box off onto its own sheet, turn that sheet into pixels. And it happens on the first frame of your animation. From frame two the animation is cheap. Frame one is where it hitches.

one transform animation12 frames
16 ms
123456789101112
frame 1: paint the box onto its own sheetframes 2 and on: slide the sheet

The usual fix is will-change: transform, which is exactly what it sounds like: a hint to the browser that this element’s transform is about to change, so it can make the sheet ahead of time instead of on frame one. Set it a moment before the animation, remove it after. The reason not to leave it on everything forever is the next two sections.

A sheet is a picture

A sheet is a picture of the element, a grid of pixels drawn once. Scale the sheet up with transform and you’re scaling that picture, and a picture blown up to three times its size looks like one: soft edges, fuzzy text. Chrome has re-drawn the sheet at the new size since Chrome 53, so scaled-up text sharpens again once it stops moving. Unless the element has will-change: transform. Then Chrome takes you at your word (the post announcing the change glosses the property as “please animate it fast”), keeps the one picture it drew, and leaves it blurry.

Sheets add up

Every sheet is a picture that has to be handed to the graphics card and kept there, and pictures are big. Worse, sheets breed. They’re stacked in order, so if a plain element sits on top of a sheet that moves, the browser has to lift that element onto a sheet of its own too, or it would end up drawn underneath the thing sliding past. One animated element can promote everything it overlaps. Promote enough things and the memory and the bookkeeping cost more than the layout you were avoiding. This is the layer explosion, and it gets its own post.

So, the rule, now that I can explain it. The browser draws a page in stages, and a change only reruns the stages after the one it lands in. transform and opacity land at the last one, where the finished sheets are being stacked. Everything else wakes up more of the browser, sixty times a second.

When I want to see this for myself rather than take my own word for it, I open the Performance panel in DevTools and record a couple of seconds of the animation. Layout shows up as purple bars, paint as green. If they’re there on every frame under a transform animation, one of the three things above is happening, and the Layers panel will show which elements got a sheet of their own, and the reason the browser gave.