Open DevTools, click a div in the Elements panel, and you’re looking at one thing. A node in a tree, with children under it and styles attached to it. That’s the model most of us carry around, and it’s the only one the browser ever shows you.
It isn’t how the browser holds your page. By the time that div is on screen it has been turned into four separate structures: the tree of elements you wrote, a tree of boxes the browser lays out from it, a list of drawing instructions cut into chunks, and a list of layers it hands to the screen. The node you clicked is only the first one.
The same tiny page as all four, one column each, with a control that sets one CSS property on one element and shows what happens to the other three:
<section> <div class="card">Alpha</div> <div class="badge">New</div> </section>
Try display: none first. The element count doesn’t move, and the three columns after it empty out.
They aren’t four copies of the same thing. Each one is the one before it, rearranged for whatever comes next, and that’s the actual reason a div ends up as four things instead of one.
Before anything can go on screen, the browser has to work out where each thing sits and how big it is. That step is called layout, and it’s the first place the model in your head comes apart from what the browser is doing, because layout doesn’t run on your DOM.
The browser builds a second structure from your DOM and lays that out instead. It’s called the box tree, and it’s what it sounds like: everything that takes up space gets a box, and the boxes are what get positioned and sized. Most of the time the two trees are close enough that you can think in the first one and be right. When they come apart, they come apart in ways that explain a lot of odd behaviour.
display: none is the obvious case. The CSS Display specification, the document that defines what each display value means, is blunt about it: none “causes the element’s entire subtree to be left out of the box tree”. Left out, rather than hidden or collapsed to nothing. The element is still in the DOM, and you can still select it, read its class list, attach a listener to it. It just never gets a box, so nothing further down the pipeline knows it exists. Ask a hidden element for its client rects and you get none, which is the browser telling you there was nothing to measure.
It goes the other way too. There are boxes with no element behind them at all. A ::before gets a box, and there’s no node for it in the DOM. And when your markup doesn’t fit the shapes the layout algorithms need, the browser quietly invents boxes to make it fit: put a <div> in the middle of a paragraph of text and the text on either side gets wrapped in boxes you never wrote. The same spec calls these anonymous boxes and defines one as “a box that is not associated with any element”.
So the second column isn’t the first column with styles attached. It’s a different tree, built from yours, and the layout you’re debugging happens in that one.
Layout worked out where everything goes. Painting is the step that works out what it looks like. For each box, the browser records the drawing it will need: fill this rectangle in this colour, put this text here in this font, round off this corner. What comes out isn’t pixels yet. It’s a list of drawing instructions, kept in the order they have to be carried out.
Two things about that list are worth getting straight, because both of them stop matching the tree.
The first is the order. Painting doesn’t walk the box tree from top to bottom. It follows paint order, which is the set of rules that decides what ends up on top of what: which elements are positioned, what their z-index is, and the groups those form, called stacking contexts. That’s why an element can sit lower in your markup and still be drawn on top of its neighbours, and it’s why z-index seems to work some days and not others.
The second is how the list gets cut up. The browser doesn’t keep one long run of instructions. It slices the run wherever the conditions change, and Blink’s own documentation describes the slicing precisely: it “segments the display item list into PaintChunks which are sequential display items that share a common property tree state”. In plainer words, a chunk is a stretch of drawing where the same transform applies, the same clipping applies, and the same effects like opacity apply, all the way through.
That last part is what the demo is doing when you pick transform. Nothing changes in the first two columns, because a transform doesn’t move a box in layout. The chunk count goes from one to two, because the badge’s transform no longer matches its neighbours, so its drawing can’t be part of the same run as theirs. Pick opacity: .5 and the same thing happens for the same reason: an effect that applies to the badge and to nothing around it.
There’s an older structure in this part of the pipeline that you’ll meet if you read around: the PaintLayer. I’m leaving it out of the four on purpose. Blink’s paint documentation calls it “an old implementation detail of Blink”, and the comment at the top of the file that defines it says “PaintLayer is an old object that handles lots of unrelated operations” and that “we want it to die at some point”. One of the things a div used to be is being deleted by the people who wrote it, so it’s a poor thing to build a mental model on.
Now the browser has to get all of this onto the screen, and it would rather not repeat the work every time something moves. So it groups the chunks into layers.
A layer is a piece of the page that the browser keeps as its own picture, and it can slide that picture around, fade it, or scale it without redrawing anything else. Most of a page lives on one layer, holding everything. An element only gets a layer of its own when the browser has a reason to give it one.
You might think a layer is something you ask for, with will-change or an animation. Mostly it’s something you get. The chunks are handed to a step called layerization, which merges them into as few layers as it can and splits one off when it has a reason to. Blink’s documentation names the handoff exactly: “the list of paint chunks then will be processed by PaintArtifactCompositor for layerization”.
That sentence has the word “list” in it twice over, because what comes out is a list as well. Layers aren’t nested inside each other the way boxes are, they’re stacked in order, and calling the result a tree would claim a containment relationship that isn’t there. Chrome moved to making this decision after painting rather than before, and shipped that in Chrome 94. The phase leading up to it is described in Chromium’s own notes as sending “a layer list instead of a tree”.
The reasons a chunk gets a layer of its own come in two kinds, and the difference between them is why the decision has to happen this late. Some are properties of the element itself: a 3D transform, will-change, a running transform or opacity animation, a video, a canvas, an iframe. Those are the direct reasons, and the browser could see every one of them coming. Notice what isn’t on that list: a plain transform, and a static opacity. That’s why those two chips split the third column and leave the fourth alone.
Overlap is the other kind. If something sits on top of a layer that moves, it has to be lifted onto a layer too, or it would end up drawn underneath. Blink’s list of compositing reasons describes this one as being “based on overlapping relationship among pending layers”, and it’s worked out after painting. It has to be. Whether one element overlaps another is a fact about where things landed, and nothing knows where things landed until they have been laid out and drawn. This is why a single animated element can hand layers to everything above it, and why a page can end up with far more layers than it has animations. That one gets its own post.
Put the four next to each other and the whole thing reads as a sentence. You have a tree of elements. From it, a tree of boxes, which is not the same tree. From those, a list of drawing runs grouped by what they have in common. From those, a list of layers the browser can move on their own.
None of it was built to complicate your life. Your tree is for you, arranged by meaning, and it’s the thing your code talks to. Layout needs boxes with sizes, not elements with meanings. Drawing needs an order, and runs that share the same conditions, not a hierarchy. The compositor needs pieces it can move without redrawing, not a structure describing what contains what. Nobody wanted four structures. Each one exists because the one before it is the wrong shape for the job that comes next.
Set a property, and you’ve edited one of the four and asked the browser to rebuild everything to its right. display: none edits the second and empties the two after it. transform leaves the first two alone and only splits the third. will-change reaches the fourth. That’s the whole reason some CSS is cheap to change and some isn’t: it depends on how far to the left you reached.
And the reason none of this stays in your head is that the browser only ever shows you the first column, which is why it’s the one we think in.