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.
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.
: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.
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.
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.
Related
- Container Queries in Components — the parent topic on containment and the query model.
- Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Container Query Units Inside Shadow DOM — what
cqiresolves against once the host is a container. - Avoiding Layout Containment Side Effects — the other behaviours containment changes.
- CSS Scoping in Shadow DOM — how
:hostparticipates in the cascade.