Making a Host a Query Container: :host, container-type, and the Sizing Trap

The natural place to declare a component’s query container is :host. The host element is the thing consumers position and size, its box is exactly the space the component has been given, and the declaration lives inside the component’s own stylesheet where it belongs. One line — :host { container-type: inline-size; } — and every rule in the shadow tree can respond to available width.

Then two things go wrong that catch nearly everyone. The component that used to shrink-wrap its content suddenly stretches to fill its parent, and the rule written to restyle :host itself never matches. Both are direct, specified consequences of what container-type does, and both have clean fixes once the model is clear. This deep dive belongs to Container Queries in Components inside Styling, Theming & CSS Encapsulation.

Problem Statement: The Badge That Grew

class StatusBadge extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host {
          display: inline-block;          /* shrink-wraps its label */
          container-type: inline-size;    /* …until this line was added */
          padding: 0.25rem 0.6rem;
          border-radius: 999px;
          background: #dbe4ff;
        }
        /* Intent: get roomier when there is space. Never matches. */
        @container (inline-size >= 200px) {
          :host { padding: 0.4rem 1rem; }
        }
        .label { font-size: 0.85rem; }
      </style>
      <span class="label"><slot></slot></span>`;
  }
}
customElements.define('status-badge', StatusBadge);
<p>Build <status-badge>passing</status-badge> as of 09:14.</p>

Before the container-type line, the badge was a small pill hugging the word “passing”. After it, the badge stretches across the entire paragraph width, and the padding rule that was supposed to compensate does nothing at all. No error, no warning — the layout simply changed.

What container-type does to an inline-block host Before the declaration the badge shrink-wraps its label; after it, inline-size containment makes the badge take its width from the parent instead of its contents. Same markup, one extra declaration Before: display: inline-block Build passing as of 09:14. width comes from the contents After: container-type: inline-size Build passing width comes from the parent: contents are ignored for inline sizing

Root-Cause Analysis

container-type: inline-size is not only a marker. CSS Containment Module Level 3 specifies that it applies layout containment, style containment, and inline-size containment to the element. Inline-size containment is defined in Level 2 as computing the element’s inline size “as if it had no contents” — the box is sized purely from its formatting context.

That is not incidental; it is the mechanism that makes the query answerable. If the badge’s width depended on its label, and the label’s font size depended on the badge’s width, there would be no fixed point. Containment breaks the cycle by declaring one direction authoritative: the outside sizes the container, the container’s size selects styles, the styles lay out the contents. The shrink-wrap behaviour is exactly the dependency that had to go.

The second failure has a different cause. @container conditions are evaluated against an ancestor query container, never the element that declares one. The rule @container (inline-size >= 200px) { :host { … } } asks: is there an ancestor of the host that is a query container at least 200px wide? Usually there is not one at all, so the rule never applies — and when there is one (say a consumer wrapped the badge in a container), it matches against the wrong box entirely, producing behaviour that varies by consumer.

Debugging Pitfall — the rule that matches "sometimes". Because an unnamed query binds to the nearest eligible ancestor container, a component whose rules target :host is not merely inert — it is unpredictable. Drop the same component inside a consumer's card that happens to declare container-type: inline-size and the rules start matching, against a box that is not the component's. Always name your containers and always target descendants, so a rule either matches your own box or does not match at all.

There is a third consequence worth planning for: style containment. It scopes counters and quotes to the element, which is harmless for most components but breaks counter-reset chains that were expected to span the boundary. If a component participates in a document-level numbered list, containment will isolate it.

Correct structure: host declares the container, a wrapper carries the styles The host declares container-type and a named container, an inner wrapper element receives every size-dependent declaration, and width fit-content restores shrink wrapping where it is wanted. :host — declares the container, never queries it container: badge / inline-size width: fit-content (restores shrink-wrap) .shell — the wrapper every @container rule targets Never @container badge (…) { :host { … } } binds to an ancestor you do not control Always @container badge (…) { .shell { … } } binds to your own host box

Production-Safe Pattern

class StatusBadge extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host {
          display: inline-block;
          /* Named, so a consumer's container can never capture our rules. */
          container: wfc-badge / inline-size;
          /* Restores the shrink-to-fit that inline-size containment removed. */
          width: fit-content;
        }
        :host([hidden]) { display: none; }

        /* Every size-dependent declaration targets the wrapper, not the host. */
        .shell {
          display: inline-flex;
          align-items: center;
          gap: 0.35rem;
          padding: 0.25rem 0.6rem;
          border-radius: 999px;
          background: var(--wfc-badge-bg, #dbe4ff);
          color: var(--wfc-badge-fg, #1f2a55);
          font-size: 0.85rem;
        }

        .dot { width: 0.5rem; height: 0.5rem; border-radius: 999px; background: currentColor; }

        @container wfc-badge (inline-size >= 200px) {
          .shell { padding: 0.4rem 1rem; font-size: 0.95rem; gap: 0.5rem; }
        }

        @container wfc-badge (inline-size >= 320px) {
          .shell { padding: 0.5rem 1.25rem; font-size: 1.05rem; }
          .dot { width: 0.65rem; height: 0.65rem; }
        }
      </style>
      <span class="shell"><span class="dot"></span><slot></slot></span>`;
  }
}
customElements.define('status-badge', StatusBadge);
<!-- Inline in prose: fit-content keeps it a pill. -->
<p>Build <status-badge>passing</status-badge> as of 09:14.</p>

<!-- In a wide panel: the consumer gives it width, the queries fire. -->
<div style="width: 360px"><status-badge style="width: 100%">passing</status-badge></div>

Three decisions carry this. The container shorthand names the container wfc-badge, so every rule binds to this component’s host and to nothing else — a consumer’s container-name: card cannot capture it and a nested copy of the same component cannot either. width: fit-content restores shrink-wrapping for the inline case; when a consumer wants the badge to fill, they set a width from outside and the queries respond. And every @container rule targets .shell, which is strictly a descendant of the queried container and therefore in scope.

The prefix on the container name matters more than it looks. Names are matched by ident, not by scope, so an unprefixed card in two different components will collide the moment one nests inside the other — the same discipline that applies to design token names.

Verification

const badge = document.querySelector('status-badge');
const shell = badge.shadowRoot.querySelector('.shell');

// 1. The host really is a query container.
console.assert(
  getComputedStyle(badge).containerType === 'inline-size',
  'host must declare inline-size containment'
);
console.assert(getComputedStyle(badge).containerName === 'wfc-badge', 'named container');

// 2. Inline usage still shrink-wraps.
console.assert(badge.getBoundingClientRect().width < 200, 'fit-content preserved');
console.assert(getComputedStyle(shell).fontSize === '13.6px', 'base arrangement at small size');

// 3. Given room, the query fires and the wrapper responds.
badge.style.width = '360px';
console.assert(
  parseFloat(getComputedStyle(shell).fontSize) > 15,
  'wide arrangement applies above 320px'
);

Chromium DevTools makes the structural check instant. Every query container carries a container badge in the Elements panel; clicking it outlines the queried box and lists the rules currently matching against it. If your @container rules do not appear in that list, they are bound to a different container — usually because they target :host or because the name is misspelled. The Computed pane also shows container-type and container-name as resolved values, which is the quickest way to confirm the declaration survived a build step.

When to Use vs When to Avoid

Situation Declare the container on :host?
Component adapts to the space a consumer gives it Yes — this is the standard shape
Component must shrink-wrap its content inline Yes, plus width: fit-content
Component’s height must grow with its content Yes — use inline-size, never size
Fixed-dimension tile with both axes constrained Use container-type: size deliberately
Component only needs to read a design token container-type: normal — style queries need no containment
Component participates in a document counter chain Reconsider — style containment isolates counters
Component is a layout primitive whose size is its content Put the container on an inner wrapper instead

The last row is the escape hatch worth knowing. Nothing requires the container to be the host: a component can declare container-type on an internal wrapper and query from deeper still. That keeps the host’s sizing behaviour completely untouched, at the cost of the container measuring the wrapper’s box rather than the space the consumer allotted — usually the same number, occasionally not.

What each container-type value costs and enables Normal enables style queries with no containment, inline-size enables width queries at the cost of shrink-wrapping, and size enables both axes at the cost of content-driven height. container-type: pick the least containment that answers your question normal the default on every element style queries: yes size queries: no no containment applied no sizing side effects token-driven density switches inline-size what components want width queries: yes height grows with content stops shrink-wrapping fix: width: fit-content the right default size both axes contained height queries: yes content no longer sets height clips or overflows fixed-dimension tiles only rarely correct for a component

Frequently Asked Questions

Why did my component stop shrink-wrapping its content?

container-type: inline-size applies inline-size containment, so the element’s inline size is computed as if it had no contents. Add width: fit-content when the shrink-to-fit behaviour was intentional, or let the parent layout supply the width.

Can a rule inside my shadow tree query the host's own size?

Only by targeting a descendant. @container conditions are evaluated against an ancestor container, so a rule whose subject is :host binds to something above the host. Wrap the content in an element and style that.

Should I always name my containers?

For components, yes. An unnamed query binds to the nearest eligible ancestor, which may be a consumer’s container or a nested copy of your own component. A prefixed name makes the binding explicit and collision-free.

Does container-type: size ever make sense for a component?

Rarely. It contains both axes, so the element stops growing with its content vertically and will clip or overflow. Reserve it for regions with externally fixed dimensions, such as a dashboard tile in a fixed grid track.