Avoiding Layout Containment Side Effects: What container-type Changes Besides Queries
container-type: inline-size reads like metadata — a label saying “queries may ask about this element”. It is not. The declaration applies containment, and containment changes how the element participates in layout, painting, fragmentation, and even counter numbering. Most of those changes are harmless or beneficial. A few of them silently break things that worked yesterday, and because the symptom appears somewhere other than the line you edited, the connection is easy to miss.
The four that actually bite in component work are: the element stops shrink-wrapping, absolutely positioned descendants get a new containing block, overflow gains a scroll container in some configurations, and counters and quotes are scoped to the element. This deep dive belongs to Container Queries in Components within Styling, Theming & CSS Encapsulation.
Problem Statement: The Tooltip That Moved and the Numbers That Restarted
class StepCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
/* Added to enable container queries. Three unrelated things broke. */
:host { display: block; container-type: inline-size; }
/* 1. Intended to escape the card and align to the page. */
.tooltip { position: absolute; inset-block-start: 0; inset-inline-end: 0; }
/* 2. Intended to continue the document's step numbering. */
.steps li::before { counter-increment: step; content: counter(step) ". "; }
/* 3. Intended to overflow visibly into the gutter. */
.badge { position: relative; inset-inline-start: -12px; }
</style>
<div class="tooltip">?</div>
<ol class="steps"><li>Install</li><li>Configure</li><li>Deploy</li></ol>
<span class="badge">new</span>`;
}
}
customElements.define('step-card', StepCard);
The tooltip, which used to position against the page, now pins to the card’s own corner. The step numbers restart at 1 in every card instead of continuing 1–9 across three cards. And on a page that also sets contain-intrinsic-size or a fixed height, content that used to spill into the gutter is clipped. Nothing in the diff touched positioning, counters, or overflow.
Root-Cause Analysis
CSS Containment Module Level 3 defines container-type: inline-size as applying layout containment, style containment, and inline-size containment. Each of those is a separate, fully specified behaviour from Level 2, and each has consequences beyond enabling queries.
Layout containment does four things at once. It makes the element establish an independent formatting context, so floats and margins no longer interact across its boundary. It makes the element a containing block for absolutely and fixed-positioned descendants, which is what re-anchors the tooltip. It makes the element a stacking context, so z-index inside it is scoped. And it means nothing outside the element can be affected by its internal layout — the property that scopes invalidation and makes container queries cheap.
Style containment scopes counter-increment, counter-reset, and quotes to the element and its descendants. A counter(step) chain running through the document stops at the boundary and starts fresh inside — which is the restarting numbering.
Inline-size containment computes the element’s inline size as if it had no contents, removing shrink-to-fit. This is the one covered in making a host a query container, and it is the most frequently hit of the four.
The overflow symptom is a consequence of the independent formatting context plus a stacking context: descendants can still paint outside the border box unless overflow says otherwise, but they no longer participate in the ancestor’s formatting context, so techniques that relied on margin collapsing or on the parent’s overflow behaviour stop working as written.
container-type was added to an ancestor. In Chromium's Elements panel the container badge on an ancestor is the fastest confirmation; in the Computed pane, contain resolves to layout style inline-size even though nobody wrote a contain declaration.
The framing that makes this tractable: containment is not a cost you pay for queries, it is the definition of what a query container is. An element whose layout is entangled with its contents cannot answer a size question before its contents are laid out. Every side effect above is the entanglement being cut.
Production-Safe Pattern
Each side effect has a specific remedy, and none of them require giving up the query.
class StepCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: block;
container: wfc-step / inline-size;
/* Restores shrink-to-fit where the design wanted it. */
width: fit-content;
max-inline-size: 100%;
}
/* FIX 1 — the tooltip must escape the card, so it cannot be an
absolutely positioned descendant of a contained element. Use the
top layer instead: popover is not laid out relative to any ancestor. */
.tooltip { position: static; }
[popover] { margin: 0; inset: auto; }
/* FIX 2 — counters are scoped by style containment, so drive numbering
from a value the consumer supplies rather than a document-wide chain. */
.steps { counter-reset: step var(--wfc-step-start, 0); list-style: none; padding: 0; }
.steps li { counter-increment: step; }
.steps li::before { content: counter(step) ". "; font-variant-numeric: tabular-nums; }
/* FIX 3 — an overhang that must escape belongs outside the container.
Put the container on an inner wrapper and let the badge sit above it. */
.shell { container: wfc-step-inner / inline-size; }
.badge { position: relative; inset-inline-start: -12px; }
@container wfc-step (inline-size >= 420px) {
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
}
</style>
<span class="badge">new</span>
<div class="shell">
<button popovertarget="tip" aria-label="What is this?">?</button>
<div id="tip" popover>Steps run in order and can be retried.</div>
<ol class="steps"><li>Install</li><li>Configure</li><li>Deploy</li></ol>
</div>`;
}
}
customElements.define('step-card', StepCard);
<step-card style="--wfc-step-start: 0">…</step-card>
<step-card style="--wfc-step-start: 3">…</step-card>
<step-card style="--wfc-step-start: 6">…</step-card>
The three remedies generalise. Anything that must escape the box should use the top layer — popover and dialog are painted in the top layer and are not positioned relative to any ancestor, so containment is irrelevant to them. Anything that needs cross-component continuity should take a value in, not read one out; a custom property is the component-friendly way to express “start at 3”, and it keeps the component’s numbering correct under any consumer arrangement. Anything that must overhang should live outside the container: move the container declaration to an inner wrapper, and the overhanging element sits above it in the tree.
For the positioning case there is a second option worth knowing: CSS anchor positioning lets an element be positioned relative to a named anchor rather than its containing block, which sidesteps containment entirely. Support is narrower than popover, so treat it as a progressive enhancement.
Verification
const card = document.querySelector('step-card');
// Containment really is applied, with all three components.
console.assert(getComputedStyle(card).contain.includes('layout'), 'layout containment applied');
console.assert(getComputedStyle(card).contain.includes('style'), 'style containment applied');
// Numbering continues across instances via the token, not a document counter.
const [a, b] = document.querySelectorAll('step-card');
const first = (el) => getComputedStyle(el.shadowRoot.querySelector('.steps li'), '::before').content;
console.assert(first(a) !== first(b), 'each card starts where the previous one stopped');
// The popover escapes: its box is not clipped by the card.
const tip = card.shadowRoot.querySelector('[popover]');
tip.showPopover();
const cardBox = card.getBoundingClientRect();
const tipBox = tip.getBoundingClientRect();
console.assert(tipBox.width > 0 && tipBox.height > 0, 'popover renders in the top layer');
console.assert(tipBox.bottom > cardBox.top, 'and is not clipped away');
The quickest DevTools confirmation for the positioning change is to select the absolutely positioned descendant and read the Layout pane: it names the containing block. Before the container declaration it names an ancestor further up; after, it names the contained element. For counters, the Computed pane on the ::before pseudo-element shows the resolved content string, which is the actual number rendered rather than the one you expected.
When to Use vs When to Avoid
| Requirement | Approach |
|---|---|
| Component adapts to its own width | Declare the container; accept the containment |
| A tooltip or menu must escape the component | popover / dialog in the top layer |
| Numbering must continue across component instances | Pass a start value as a custom property |
| A decoration must overhang the box | Put the container on an inner wrapper |
| Component must shrink-wrap inline | width: fit-content alongside the container |
| Component participates in a document-wide counter chain | Do not contain the host; contain a wrapper instead |
| Only a design token needs to drive layout | container-type: normal plus a style query |
The last row is the underused escape hatch. Style queries need no size containment at all, so a component whose only variability is “compact or comfortable” can get the whole benefit with none of the side effects on this page.
Frequently Asked Questions
Why did my absolutely positioned tooltip start anchoring to the component?
Layout containment makes the element a containing block for absolutely and fixed-positioned descendants. Anything that must escape should use the top layer via popover or dialog, which is not positioned relative to any ancestor.
Why did my CSS counters restart inside each component?
Style containment scopes counter-increment, counter-reset, and quotes to the contained element. Pass a starting value in as a custom property instead of relying on a document-wide chain.
Can I get container queries without the containment?
Only for style queries, which work with container-type: normal and apply no containment at all. Size queries require containment by definition — it is what makes the size knowable before the contents are laid out.
Does containment hurt or help performance?
It helps, substantially. Layout and style containment confine invalidation to the element’s subtree, so a change inside one card cannot force a document-wide reflow. The side effects on this page are correctness concerns, not performance ones.
Related
- Container Queries in Components — the parent topic on containment and query evaluation.
- Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Making a Host a Query Container — the shrink-wrap side effect in detail.
- Container Query Units Inside Shadow DOM — the unit half of the feature.
- Optimizing Style Recalculation in Large Component Trees — the invalidation scoping containment buys you.