Layout
Layout turns computed styles into geometry: a position and a size for every box, resolved against containing blocks, by the algorithm of whichever formatting context the box lives in. It is the least parallel stage and the one where one change propagates furthest, so this part is as much about containing relayout as about running it: the box model, block and inline layout and line breaking, the flex and grid algorithms step by step, positioning and stacking, incremental layout, forced synchronous layout, and reserving space.
What layout computes, and the tree it runs on
"Style is done. Every element knows its width is 50%. Why is there still work?"
Because 50% of what? Layout turns computed styles, many of which are relative, into absolute geometry: an x, a y, a width and a height for every box, in a coordinate system, resolved against the containing block. It runs on the layout tree, which is built from the DOM plus computed styles and is not the same shape as either.
- display: none produces no layout object. The subtree is absent.
- ::before and ::after produce layout objects with no DOM node.
- Anonymous boxes are inserted: text directly inside a block gets an anonymous block wrapper; a table without tbody gets one; a flex container's bare text gets an anonymous flex item.
- display: contents removes the element's own box and promotes its children, so it is in the DOM and absent from layout.
- Inline and block are different object types with different algorithms, chosen from the computed display value.
what each box ends up with: position (x, y) relative to its container's content box size (width, height) of its border box overflow the rectangle its content actually occupies fragments one per line for inlines, one per column or page when fragmented in LayoutNG these are immutable fragment objects, which is what made layout cacheable and parallel-safe.
The box model and formatting contexts
Every box is laid out by the rules of the formatting context it participates in. The context is established by the parent, and it is the algorithm. Choosing display on a container is choosing which algorithm its children get.
| Context | Established by | Lays out children | Characteristic |
|---|---|---|---|
| Block (BFC) | Root, display: flow-root, overflow ≠ visible, floats, absolutes, flex and grid items | Vertically, full width, in order | Margins collapse between siblings and parent/child; floats are contained by a new BFC |
| Inline (IFC) | A block containing only inline content | Into line boxes, wrapping | Line height, baseline alignment, bidi, white-space handling |
| Flex | display: flex / inline-flex | One axis, grow and shrink | Content-aware sizing; order; wrapping into lines |
| Grid | display: grid / inline-grid | Two axes, tracks and cells | Explicit placement; fr; subgrid; auto-placement |
| Table | display: table and friends | Rows and columns with auto or fixed algorithm | Column widths depend on all rows: whole-table dependency |
| Ruby, math | display: ruby, math | Special | Rare |
- Margin collapse. Adjacent vertical margins in a BFC merge to the larger. A child's top margin can escape through its parent if nothing separates them. A new BFC (flow-root, overflow, padding, border) stops it. Flex and grid items never collapse margins.
- box-sizing. content-box is the default and means width excludes padding and border.
*, *::before, *::after { box-sizing: border-box }is in every reset for a reason. - Percentages resolve against the containing block, and for height only if the containing block's height is definite. A percentage height inside an auto-height parent computes to auto.
- min-width: auto on flex and grid items means "my content's minimum size", which is why long words and wide tables refuse to shrink inside flex rows.
min-width: 0oroverflow: hiddenon the item. - Replaced elements (img, video, canvas, iframe) have intrinsic sizes and aspect ratios. Width and height attributes on an img let layout reserve space before the bytes arrive, which is the single biggest CLS fix.
Block and inline layout, and line breaking
Block layout is the easy one: stack boxes vertically. Inline layout is where the real algorithm lives, because text has to be shaped, measured and broken into lines.
- Collect inline content into a single run: text nodes, inline boxes, replaced elements, with bidi reordering applied.
- Shape the text. Characters become glyphs via the font's shaping engine (HarfBuzz in Blink and Gecko). Ligatures, kerning, complex scripts. Shaping is cached per run, which is why changing one character in a long paragraph is cheaper than it looks.
- Find break opportunities. Per the Unicode line breaking algorithm, plus CSS overrides: white-space, word-break, overflow-wrap, hyphens. Languages differ; CJK can break almost anywhere, Thai needs a dictionary.
- Fill lines greedily. Add items until the next one does not fit, break at the last opportunity, start a new line. Browsers use greedy breaking, not Knuth-Plass;
text-wrap: prettyandbalancerun a limited lookahead on top. - Size each line box. Height from line-height and the tallest inline; baseline alignment from vertical-align and font metrics (ascent, descent, which is why line-height: 1 can clip).
- Position fragments. Each inline box may be split across lines; each piece is a fragment with its own rectangle, which is what getClientRects() returns.
why inline layout cost scales with text: shaping O(characters), cached by run breaking O(characters), re-run when width changes line boxes O(lines) a width change on a paragraph re-breaks every line below the change. a font-size change re-shapes and re-breaks everything. long text in a container whose width animates is a layout cost per frame proportional to the text.
Flexbox: the algorithm
Flexbox is one-dimensional: items along a main axis, lines along a cross axis. The specification's algorithm has nine steps; four of them do the work.
- Hypothetical main size. From flex-basis (or width/height if basis is auto, or content if both are auto), clamped by min and max.
- Line collection. With wrap, items are split into lines where the sum of hypothetical sizes exceeds the container.
- Resolve flexible lengths. Per line: free space = container minus sum minus gaps. Positive: distribute by flex-grow. Negative: distribute by flex-shrink weighted by basis. Items that would violate min or max are frozen at the limit and the remainder is redistributed. Loop until stable.
- Cross size. Each line is as tall as its tallest item. align-items stretches or aligns items within the line; align-content places lines in the container.
- Main-axis alignment. justify-content distributes any remaining space (which exists only when nothing grows).
- flex: 1 means flex: 1 1 0%: basis zero, so all free space is shared equally regardless of content. flex: auto means 1 1 auto: content first, then share the rest. Different results with different content.
- Shrinking is weighted by basis, so the biggest item gives up the most, which is usually what you want and sometimes surprising.
- min-width: auto blocks shrinking below content. The overflowing flex row is this, every time.
- Percent heights inside flex items are late-resolved, because the line height is not known until step 4.
- Nested flex containers multiply passes. Each level may need two layouts (measure, then final), so deep flex nesting is a real cost on wide tables of flex rows.
Grid: track sizing and placement
Grid is two-dimensional and its algorithm is correspondingly larger. Track sizing resolves columns and rows; placement puts items in cells; the two interact when tracks are content-sized.
- Initialise. Each track gets a base size and a growth limit from its definition: fixed lengths are both;
autoandmin-contentstart at zero with growth from content;fris skipped until step 4;minmax(a, b)sets both. - Resolve intrinsic sizes. Items spanning one track contribute their min-content and max-content to that track's base and limit. Then items spanning more tracks distribute their contribution across them.
- Maximise. If free space remains and tracks have growth limits above their base, grow them.
- Expand flexible tracks. Remaining space is divided among fr tracks by weight. An fr track's minimum is its content minimum (unless
minmax(0, 1fr)). - Expand stretched auto tracks. If space still remains, auto tracks share it.
- Repeat for the other axis, using the now-known column sizes to measure item heights.
- Explicit placement first: items with both grid-row and grid-column set, then those with one.
- Auto-placement walks the grid in order, row by row (or column by column), placing each item in the first cell that fits.
grid-auto-flow: densebackfills earlier holes, reordering visually relative to source, which matters for accessibility. - Implicit tracks are created when placement runs past the explicit grid, sized by grid-auto-rows and grid-auto-columns.
- Subgrid lets a nested grid adopt its parent's tracks on an axis, so nested items align to the outer grid.
auto rows.Positioning, containing blocks and stacking
Position removes a box from normal flow in different ways, and each way changes what the box is sized and placed against.
- static, relative. The nearest block container ancestor's content box. relative then offsets from where it would have been, without affecting others.
- absolute. The nearest ancestor with position other than static, or one with transform, filter, perspective, contain: layout, or will-change: transform. Those last ones surprise people: a transform on a parent captures absolutely positioned descendants.
- fixed. The viewport, unless an ancestor has transform, filter or similar, in which case that ancestor. The "fixed header breaks inside an animated container" bug.
- sticky. In flow, but offset within its nearest scrolling ancestor once it crosses the threshold. The sticky box is constrained to its containing block, so a sticky inside a short parent stops early.
- z-index only works on positioned boxes (and flex and grid items). On a static box it is ignored.
- A stacking context is created by a positioned box with z-index other than auto, opacity below 1, transform, filter, isolation: isolate, will-change for those properties, mix-blend-mode, contain: paint, and a few more.
- z-index is compared only within a stacking context. A z-index of 9999 inside a context with z-index 1 sits below a z-index 2 sibling of that context. This is the whole explanation for every "my modal is behind the header" bug.
- Paint order within a context: background, negative z, blocks, floats, inlines, z-index 0 and auto positioned, positive z. Part 5 draws it.
z-index plus a walk up the ancestors looking for anything that creates a context is the method. When z-index does not work, the answer is always an ancestor, never a bigger number.Overflow, scrolling containers and fragmentation
- visible is the default: content spills, and the overflow rectangle is tracked for hit testing and painting.
- hidden, clip, scroll, auto clip to the padding box. hidden and auto make the box a scroll container; clip does not, which is cheaper and does not establish a new formatting context for the same reason.
- A scroll container gets a scrollable overflow rectangle, scrollbars (which take layout space unless overlay or
scrollbar-gutter), and, on the compositor, its own scrolling layer so the main thread is not involved in scrolling it. Part 5. - overflow on one axis forces the other:
overflow-x: hiddenwithoverflow-y: visiblecomputes to auto on y. A frequent cause of unexpected scrollbars.
- Multi-column, paged media and regions split a box's content across fragmentainers. Each fragment gets its own rectangle.
- break-before, break-after, break-inside control where.
break-inside: avoidis the one that matters for print and for multicol cards. - Layout cost multiplies because content may be laid out, found not to fit, and laid out again in the next fragment.
Layout invalidation and incremental layout
Like style, layout does not rerun for the whole tree on every change. The engine marks boxes dirty, propagates the dirt along dependency edges, and relayouts only the dirty subtree, with caching for what did not change.
- The changed box, for a change to its own size-affecting style.
- Its ancestors, up to the nearest box whose size cannot be affected: a box with a fixed size and
contain: layoutorsize, or an absolutely positioned box with a definite size. Without containment, dirt propagates to the root, because a parent's height depends on its children. - Its following siblings in block flow, because their positions depend on it.
- Its descendants, if its width changed, because percentages and wrapping depend on the container width.
- Fragment results keyed by the constraint space (available width, mode, etc.). A subtree laid out at the same width with unchanged content reuses its fragment without recomputing. This is why relayout after a change deep in one column is cheap if the column width did not change.
- Intrinsic sizes (min-content, max-content) per box, invalidated on content change.
contain: layout promises the engine that nothing inside affects anything outside, so dirt stops at the boundary. contain: size promises the box's size does not depend on its contents. content-visibility: auto does both for off-screen content and skips its layout entirely until it approaches the viewport. On a long list, content-visibility: auto on each item is the single biggest layout win available, with the caveat that scrollbar size estimates need contain-intrinsic-size.Forced synchronous layout and thrash
"My loop sets a style and reads offsetHeight. It is 200 items. Why is it 300 ms?"
Because each read forces the engine to run layout immediately so the answer reflects the write before it. Layout that would have run once at the end of the task runs 200 times inside it. This is forced synchronous layout, and in a loop it is layout thrash.
- Reads before writes. In any task, gather every geometry value first, then apply every style change. Layout is clean when you read and dirtied once when you write.
- Use requestAnimationFrame for the writes if they depend on the reads and you want them to land before the next paint.
- Use ResizeObserver instead of polling. It delivers sizes after layout, in a batch, without forcing anything.
- Use IntersectionObserver instead of getBoundingClientRect in scroll handlers. Same reason.
- Cache what does not change. A container's width read once at the top of a loop is one layout, not n.
- The first read in a task is free if nothing has dirtied layout since the last frame. It is the read after a write that costs.
- Route
/layout/thrashruns the write-read loop on 300 cards. Record it. Find the comb of Layout events and the red triangle on each marking "forced reflow". - Toggle the "batched" variant and record again. One Layout, at the end.
- Route
/layout/containmentchanges one card's text in a list of 2,000. Record with and withoutcontain: layouton the cards and compare the Layout event's "nodes that need layout". - Route
/layout/content-visibilityscrolls a 10,000-row list with and withoutcontent-visibility: auto. Compare total Layout time in the summary.
Intrinsic sizing, aspect ratio and reserving space
Layout shift is layout running again with different inputs after the user has started reading. The inputs that change late are sizes the engine could not know early: images, fonts, embeds, late content. Intrinsic sizing is how you tell it early.
- width and height attributes on img and video. The engine computes the aspect ratio from them before the file arrives and reserves the box. With CSS
width: 100%; height: autothe ratio holds at any size. This alone fixes most image CLS. - aspect-ratio. The same for any box:
aspect-ratio: 16 / 9on an embed container. - min-height on containers that fill late. A skeleton the same height as the content.
- contain-intrinsic-size. For
content-visibility: autocontent, the placeholder size used while it is skipped. - Metric-matched fallback fonts. Part 3. The swap does not reflow if the fallback has the same advance widths.
- Never insert above existing content unless in response to a user action. Banners, ads and "new items" bars go below or overlay.
the sizes layout works with: min-content the narrowest the box can be without overflowing (longest word) max-content the width if nothing wrapped fit-content clamp(min-content, available, max-content) stretch fill the container width: auto on a block = stretch width: auto on a float = fit-content width: auto on a flex item = content-based, then flexed CLS is measured in Part 12. every entry in it traces to a size that arrived late.
Reading layout cost in the profile
| What you see | What it means | What to do |
|---|---|---|
| Layout, purple, with a red corner, inside a script block | Forced synchronous layout | Reorder reads before writes; check the call stack in the summary for the read |
| A comb of many small Layout events in one task | Thrash in a loop | Batch; or use FastDOM-style read/write phases |
| One large Layout after a small change | Dirt propagated to the root | Add containment at a boundary; give containers definite sizes |
| Layout on every frame of an animation | Animating a layout property (width, height, top, margin) | Animate transform instead; Part 5 |
| Layout on scroll | A scroll handler reading geometry, or sticky/position: fixed with layout dependencies | IntersectionObserver; passive listeners; move to the compositor |
| Layout Shift entries in the Experience track | CLS | Click one: it names the shifted element; reserve its space |
#document for a change inside one card means containment is missing. Those two numbers are the whole diagnosis.