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.

Three behaviours that change the moment container-type is declared Absolute positioning re-anchors to the contained element, counters restart inside it, and overflow can become clipped, none of which are edits to those features. One declaration, three unrelated regressions 1. Positioning re-anchors page (was the anchor) card is now the containing block layout containment makes the element a containing block for absolute and fixed descendants 2. Counters restart card A: 1, 2, 3 card B: 1, 2, 3 card C: 1, 2, 3 style containment scopes counters and quotes to the element, breaking the chain 3. Overflow is clipped badge cannot escape contained boxes establish an independent formatting context gutter overhang disappears

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.

Debugging Pitfall — blaming the wrong commit. Every one of these regressions appears in code the author did not touch, often in a different file, sometimes only in one consumer's page. When positioning, numbering, stacking, or margin behaviour changes for no visible reason, check whether a 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.

Which containment type produces which behaviour change Layout containment produces the formatting context, containing block and stacking context changes, style containment scopes counters and quotes, and inline-size containment removes shrink to fit. layout containment new formatting context containing block stacking context scoped invalidation three costs, one big win style containment counters scoped quotes scoped style recalc scoped only matters if you use document-wide counters inline-size containment no shrink-to-fit in the inline axis block size still follows content size is knowable before layout this is what makes the query answerable in a single pass

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 layerpopover 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.

Where to put the container so each requirement still works Content that must escape the box goes in the top layer, overhanging decoration sits above the container on an inner wrapper, and continuity across instances comes from a custom property passed in. Structure that keeps the query and loses the side effects :host — the container (contained from here down) badge overhang sits above .shell .shell — inner container, queried by the layout rules ordered steps, token-numbered popover trigger button everything here is free to use container units and @container rules popover panel — painted in the top layer --wfc-step-start — continuity passed in

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.