Skip to content

Layout Containment

advanced15 min read

The Problem: Global Layout

Here is a question that should scare you: when you change the height of a single card on your page, how much of the page does the browser recalculate? By default, potentially the entire thing. Adding a paragraph to a <div> can change the height of every subsequent sibling, the parent's scrollbar, and trigger a cascade of layout recalculations across the document. Layout is global — the browser must verify that nothing changed anywhere.

<main>
  <section class="hero">...</section>
  <section class="features">
    <div class="card">This card's height changes</div>
    <!-- Every sibling below recalculates position -->
  </section>
  <section class="pricing">...</section>   <!-- Shifts down -->
  <section class="testimonials">...</section> <!-- Shifts down -->
  <footer>...</footer>                       <!-- Shifts down -->
</main>

A single card expanding forces the browser to recalculate layout for the entire document from that point down. On a page with 5,000 DOM nodes, this can take 10-30ms — an entire frame budget.

Mental Model

Imagine a spreadsheet where changing one cell recalculates the entire sheet. CSS containment is like telling the spreadsheet: "This range of cells is independent — changes inside it cannot affect cells outside." The spreadsheet engine can skip recalculating everything outside the contained range. The browser works the same way: contain creates boundaries that limit the scope of layout, paint, and style recalculations.

The contain Property

So how do you tell the browser "this section is independent, stop recalculating everything"? The contain property accepts one or more containment types:

.widget {
  contain: layout;    /* Layout changes inside don't affect outside */
  contain: paint;     /* Painting is clipped to this element's bounds */
  contain: size;      /* This element's size is independent of children */
  contain: style;     /* Counters and quotes scoped to this subtree */

  /* Shorthands */
  contain: content;   /* = layout + paint + style (safe default) */
  contain: strict;    /* = layout + paint + size + style (maximum containment) */
}

Layout Containment

.card {
  contain: layout;
}

Effects:

  • The element acts as a containing block for absolutely/fixed positioned descendants
  • Layout changes inside the element do not trigger layout recalculation outside
  • The element establishes an independent formatting context (like overflow: hidden without clipping)
// Without contain: layout — layout scope is entire document
card.style.height = '500px'; // Browser recalculates layout for entire page

// With contain: layout — layout scope is limited to card's subtree
card.style.height = '500px'; // Browser recalculates only the card's contents
Quiz
A page has 3,000 DOM nodes. A card component with contain: layout has 50 nodes inside it. When the card's internal content changes height, how many nodes does the browser recalculate layout for?

Paint Containment

.widget {
  contain: paint;
}

Effects:

  • Content that overflows the element's bounds is not painted (implicit clipping)
  • The element acts as a containing block for absolutely positioned descendants
  • The browser can skip painting this element entirely if it is off-screen

Basically, you are telling the browser: "Nothing inside this element will ever be visible outside its bounds." This lets the browser skip paint for off-screen contained elements entirely — it knows no descendant can poke out and be visible.

Size Containment

.widget {
  contain: size;
  width: 300px;
  height: 200px;
}

Effects:

  • The element's size is determined by its explicit width/height, ignoring children
  • The browser does not need to lay out children to know this element's size
  • If no explicit size is set, the element collapses to 0×0
Common Trap

contain: size without explicit dimensions collapses the element. This is the most common mistake with size containment — developers add contain: strict (which includes size) without setting width and height, and the element disappears. Always pair size containment with explicit dimensions.

The content and strict Shorthands

/* Safe for most use cases */
.card {
  contain: content; /* layout + paint + style */
  /* Children's content determines the card's size (no size containment) */
  /* But layout/paint changes are scoped to this subtree */
}

/* Maximum containment — requires explicit sizing */
.fixed-widget {
  contain: strict; /* layout + paint + size + style */
  width: 300px;
  height: 200px;
  /* Browser knows everything about this element without looking at children */
}
Quiz
You apply contain: strict to a card without setting width or height. What happens?

content-visibility: auto

Now for the real powerhouse. content-visibility is containment's most impactful application — and honestly, it might be the single highest-ROI CSS property for long pages. It tells the browser to skip rendering for off-screen elements entirely:

.section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}

When an element with content-visibility: auto is off-screen:

  • No style computation for descendants
  • No layout for descendants
  • No paint for descendants
  • The element retains its contain-intrinsic-size for scroll height calculation

When the element scrolls into view, the browser renders it on demand.

Execution Trace
Initial load
Page has 50 sections with content-visibility: auto
Browser renders only the ~3 visible sections. 47 sections skipped entirely.
Scroll down
Section 4 enters viewport
Browser renders section 4 on demand. Section 1 (now off-screen) may have rendering skipped on next frame.
Performance
Initial render: 50 sections → 3 rendered
Up to 94% reduction in initial rendering work. Layout goes from O(all sections) to O(visible sections).

contain-intrinsic-size

There is a catch, though. The browser still needs to know the element's size for scrollbar calculation, even when content is not rendered. contain-intrinsic-size provides that estimate:

.section {
  content-visibility: auto;

  /* Fixed estimate */
  contain-intrinsic-size: 0 500px;  /* width: auto, height: 500px estimate */

  /* Auto-remembering: uses last rendered height, falls back to 500px */
  contain-intrinsic-size: auto 500px;
}

The auto keyword tells the browser: "Use the actual rendered height if you've measured it before, otherwise use 500px." As the user scrolls, the browser learns each section's true height and stores it, making scrollbar position progressively more accurate.

Quiz
A page has 100 article cards with content-visibility: auto. Only 8 are visible. How much rendering work does the browser do on initial load?

Measuring Containment Impact

Performance Panel Comparison

// Without containment
console.time('no-containment');
for (let i = 0; i < 100; i++) {
  items[0].style.height = (100 + i) + 'px';
  // Force synchronous layout
  document.body.offsetHeight;
}
console.timeEnd('no-containment');

// With containment
items.forEach(item => item.style.contain = 'content');
console.time('with-containment');
for (let i = 0; i < 100; i++) {
  items[0].style.height = (100 + i) + 'px';
  document.body.offsetHeight;
}
console.timeEnd('with-containment');

// Typical results on a page with 2000 DOM nodes:
// no-containment: 850ms
// with-containment: 120ms (7x faster)

content-visibility Impact

Google's case studies show:

  • Blog with 50 articles: Initial rendering time reduced from 232ms to 30ms (7.7x faster)
  • Infinite scroll feed: Scroll jank eliminated — off-screen items have zero rendering cost
  • Documentation sites: Pages with 100+ headings render in under 50ms instead of 400ms+
How the browser determines 'off-screen'

For content-visibility: auto, the browser uses IntersectionObserver internally to determine when an element enters or exits the viewport (plus a margin). The default margin is implementation-dependent but typically extends beyond the viewport to start rendering before elements scroll into view. This prevents pop-in artifacts. You cannot customize this margin — it is managed by the browser's heuristics.

Practical Patterns

Long Scrollable Lists

.list-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 80px; /* Estimated row height */
}

Tab Panels

.tab-panel {
  contain: strict;
  /* Tab panels have fixed dimensions */
}
.tab-panel[hidden] {
  content-visibility: hidden;
  /* Fully skip rendering for hidden tabs */
  /* Unlike display:none, state is preserved (form inputs, scroll position) */
}

Complex Widgets

.dashboard-widget {
  contain: content;
  /* Each widget is independent — layout changes in one don't affect others */
}
content-visibility: hidden vs display: none

content-visibility: hidden hides the element and skips all rendering, but unlike display: none, the element retains its internal state (form values, scroll positions, focus). It also retains its layout slot. Think of it as "offscreen rendering paused." Use it for tab panels and conditional content that should preserve state.

Quiz
You want to hide a tab panel but preserve its form input values and scroll position. Which CSS approach works?
Key Rules
  1. 1CSS contain creates layout boundaries — changes inside a contained element do not trigger recalculation outside it.
  2. 2contain: content (layout + paint + style) is the safe default for most components.
  3. 3contain: strict (adds size) requires explicit dimensions or the element collapses to 0×0.
  4. 4content-visibility: auto skips style, layout, and paint for off-screen elements — the most impactful single optimization for long pages.
  5. 5Always pair content-visibility: auto with contain-intrinsic-size for correct scrollbar behavior.
  6. 6content-visibility: hidden preserves internal state (unlike display: none) while skipping all rendering work.
  7. 7Containment turns O(total DOM) layout into O(subtree) layout — the more complex your page, the bigger the win.
Interview Question

Q: Your documentation site has 200 sections on a single page. Initial render takes 800ms and scrolling janks. How would you use CSS containment to fix this?

A strong answer covers: apply content-visibility: auto with contain-intrinsic-size: auto <estimated-height> to each section. This skips rendering for the ~190 off-screen sections on initial load, reducing render time from O(200 sections) to O(~10 visible sections). For scrolling, contained sections limit layout recalculation scope. Mention that contain-intrinsic-size: auto remembers previously measured heights for accurate scrollbar sizing. Bonus: use the Performance panel to measure before/after layout times, check that the auto margin is sufficient to prevent pop-in, and note that content-visibility is supported in Chrome, Edge, and Firefox (Safari added support in 17.x).