Using contain:layout to Isolate Reflow Scope
A textarea that grows one line taller re-runs layout for every sibling card, header, and footer on the page — because the layout phase treats one geometry change as a dirty signal that walks up to the document root and back down. contain: layout cuts that walk short by declaring the element an independent layout root: whatever resizes inside it is resolved inside it, and the box the element presents to its parent never changes as a side effect.
This guide sits inside CSS Containment Strategies, part of the broader Layout and Paint Optimization topic. Where the performance-benchmark guide measures how much containment saves and where it leaks, this one is narrower: it explains exactly what the layout token does to the reflow graph, and how to confirm the boundary is holding in a trace.
The Minimal Reproduction
A comment feed where each row hosts an auto-growing input. Type into one input and every row below it — and every unrelated widget on the page — gets a fresh layout pass.
<main class="feed">
<article class="row">…</article>
<article class="row">
<div class="composer" contenteditable></div> <!-- grows on keystroke -->
</article>
<!-- 400 more rows -->
</main>
.row {
/* BAD: no boundary — the composer's height change is visible to the
document, so the layout engine must re-flow every following sibling
and re-resolve the height of .feed and <main> on every keystroke */
display: block;
}
Nothing here is wrong semantically. The cost is structural: the composer’s height is a value its ancestors depend on, so changing it dirties the ancestors, which dirties their other children. One keystroke, four hundred re-flowed rows.
How One Size Change Dirties the Whole Layout Tree
Layout runs on the main thread as a single synchronous tree walk over the render tree — Chromium calls its nodes LayoutObjects. When you mutate a property that affects geometry, the engine does not immediately recompute anything; it sets a NeedsLayout dirty bit on that object and then propagates the mark upward to every ancestor, because a child growing taller can change the parent’s content height, which can change the grandparent’s, up to the viewport. This upward mark is the MarkContainerChainForLayout step.
When the next layout is flushed — at the start of a frame, or synchronously if some script reads offsetHeight first — the engine starts at the highest dirty ancestor and walks down. Any subtree it enters that has a dirty bit gets re-measured; clean siblings of a dirty node are usually skipped, but if the shared parent’s size changed, all of its children must be re-placed. That is why a single auto-growing input ripples outward: its height feeds the parent’s block flow, and the parent then re-positions everything after it.
The key data structure is the container chain — the pointer path from a node to its containing block ancestors. Containment works by editing what that chain is allowed to see.
What contain: layout Establishes as a Relayout Boundary
contain: layout makes four promises to the layout engine, and each one severs a different edge in the dependency graph. First, the element becomes an independent formatting context, so its internal box flow can never merge with content outside it (no margin collapsing across the boundary, no floats escaping). Second — the load-bearing guarantee — the element’s own size is computed as if it had no contents: its box is sized by its own CSS (explicit or intrinsic constraints from the parent), not by what grows inside it. Third, its descendants are laid out with the element as their fixed containing block. Fourth, it cannot be affected by, and cannot affect, the layout of things outside it.
The consequence for the dirty-bit walk is direct: when the composer inside a contained row grows, the NeedsLayout mark still propagates up the container chain — but it stops at the contained element, because the contained element’s size does not depend on its children. The engine reaches the boundary, sees that the ancestor’s geometry cannot have changed, and never marks <main> or the sibling rows. Layout re-runs for one subtree instead of the whole document.
Note what layout does not promise. It does not isolate paint — an over-large child can still overflow and repaint outside the box unless you add paint (or overflow: hidden). It does not skip rendering for off-screen content the way content-visibility does. And it does not change style recalculation, which runs before layout and has its own invalidation rules. contain: layout is a scalpel aimed at exactly one phase: the geometry pass.
Reading the Trace and Applying the Fix
In a Performance recording, the un-contained version shows a single Layout event under the keystroke whose duration scales with the row count, and whose Nodes That Need Layout figure is in the hundreds. Contained, the same interaction produces a Layout event with a handful of nodes and a flat, count-independent duration. The signature to look for is the layout root the engine chose — an un-contained mutation roots layout at the document; a contained one roots it at your element.
── Un-contained keystroke ────────────────────────────
Event: Keypress 0.4 ms
Recalculate Style 2 elements 0.3 ms
Layout 412 nodes 9.8 ms ◀ rooted at #document
└ layoutRoot: HTMLHtmlElement whole feed re-placed
Update Layer Tree 1.1 ms
Paint 2.0 ms
Frame total ................................ 13.6 ms ▶ over 16.6 ms budget
── With contain: layout on .row ──────────────────────
Event: Keypress 0.4 ms
Recalculate Style 2 elements 0.3 ms
Layout 3 nodes 0.6 ms ◀ rooted at .row
└ layoutRoot: HTMLElement.row only this subtree
Update Layer Tree 0.4 ms
Paint 1.2 ms
Frame total ................................. 2.9 ms ▶ inside budget
The layoutRoot line is the whole diagnosis: it names the node the engine started the downward walk from. If it says HTMLHtmlElement when only one component changed, the container chain leaked past your intended boundary and containment is either absent or defeated (commonly by an ancestor reading geometry mid-batch — see Forced Synchronous Layouts).
The fix is one declaration, plus a size hint so the box does not itself depend on its contents. The composer can still grow up to its own max-height; beyond that it scrolls, which keeps the row’s outer box stable.
/* BEFORE — the row's height is defined by its contents,
so a child growing re-flows the whole feed */
.row {
display: block;
}
.composer {
min-height: 2.4rem; /* grows unbounded with typed content */
}
/* AFTER — the row is an independent layout root */
.row {
contain: layout; /* row's box no longer depends on child size;
NeedsLayout marks stop at this element */
min-height: 2.4rem; /* give the row an intrinsic floor so its own
size is resolvable without measuring children */
}
.composer {
min-height: 2.4rem;
max-height: 12rem; /* cap growth; overflow scrolls instead of
expanding the contained box */
overflow-y: auto;
}
If the composer must be allowed to push the row taller (an expanding card rather than a scrolling one), contain: layout still helps: the row’s height change is now the only thing that escapes, and it escapes cleanly as a single box-size delta rather than as a full re-measure of the subtree. For rows whose height is genuinely fixed, promote to contain: layout paint so paint invalidation is bounded too, or contain: strict when you can also supply contain-intrinsic-size. Pair containment with a batched read/write discipline — batching DOM reads and writes — so nothing forces a synchronous flush that measures across the boundary anyway.
| Pipeline phase | Constraint contain:layout adds | Cost removed |
|---|---|---|
| Style recalc | none (runs unchanged before layout) | — |
| Layout | element is an independent layout root; child size invisible to parent | full-document re-flow → one subtree |
| Layer tree update | fewer geometry changes to reconcile | ancestor layer resizing |
| Paint | only bounded with paint/overflow added |
not isolated by layout alone |
Verification Checklist
Frequently Asked Questions
Does contain:layout stop a child's size change from ever affecting the parent?
It stops the child’s size from affecting the parent’s layout computation. The contained element’s own box is sized by its CSS, not by its contents, so the parent never re-measures because of an internal change. If you give the contained element an explicit or min-height size, its outer box stays constant and nothing escapes. If you leave it auto-sized so the box itself grows, that single box-size delta still propagates — but the subtree beneath it is no longer re-walked.
What is the difference between contain:layout and contain:strict?
contain: layout isolates the layout (geometry) phase only. contain: strict is shorthand for layout paint size, which additionally bounds paint invalidation and forces the element to be sized without any input from its contents (contain: size). Use layout when you want reflow isolation but still want the element to size to its content; use strict when the element has a known size and you want the strongest isolation, supplying contain-intrinsic-size so the box is resolvable.
Why is my Layout event still rooted at the document even with contain:layout applied?
Almost always because something forced a synchronous layout that measured across the boundary — a script reading offsetHeight, getBoundingClientRect, or scrollTop on an ancestor after a write. A forced flush recomputes from the highest dirty node regardless of containment. Batch all reads before all writes, and confirm the property you mutated is actually one containment scopes. Style recalculation, which happens before layout, is not affected by contain: layout at all.
Is there a rendering cost to adding contain:layout to hundreds of elements?
The property itself is nearly free to declare, but making every element an independent formatting context can change wrapping and margin-collapsing behaviour, and it disables some cross-element layout optimizations the engine would otherwise make for a flat list. Apply it to the boundary you actually mutate — the repeating component root — rather than to every leaf. Measure with a trace; the win comes from bounding invalidation on the elements that change, not from blanket application.
Related Guides
- CSS Containment Strategies — the parent overview of when and where each containment token pays off.
- CSS Contain Property Performance Benchmarks — measured savings and the intrinsic-size cases where containment leaks.
- Using content-visibility for Offscreen Content — skip layout and paint entirely for subtrees outside the viewport.
- Forced Synchronous Layouts — why a mid-batch geometry read defeats the boundary you just declared.