Container Query Units Inside Shadow DOM: What cqi Actually Resolves Against
cqi looks like a component-scoped unit and behaves like an ancestor-scoped one. Writing font-size: 4cqi inside a shadow tree does not mean “4% of this component’s width” — it means “4% of the inline size of the nearest ancestor query container”, and which ancestor that is depends on what the consumer’s page happens to declare. Drop the same component into two applications and the type can come out at two different sizes, with nothing in the component’s own source to explain it.
The fix is one line, but the model behind it is worth understanding properly, because the same rule governs cqw, cqh, cqb, cqmin, and cqmax, and it explains why container units are safe inside a shadow tree in a way that viewport units never were. This deep dive belongs to Container Queries in Components within Styling, Theming & CSS Encapsulation.
Problem Statement: Type That Changes Size Depending on the Host Page
class QuoteBlock extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; }
/* Intent: scale the quote with the component's own width. */
blockquote { font-size: clamp(1rem, 5cqi, 2.5rem); margin: 0; }
cite { font-size: clamp(0.8rem, 2cqi, 1rem); }
</style>
<blockquote><slot></slot></blockquote>
<cite><slot name="source"></slot></cite>`;
}
}
customElements.define('quote-block', QuoteBlock);
<!-- Application A: no query container anywhere. -->
<article style="width: 640px">
<quote-block>Encapsulation is a contract, not a wall.<span slot="source">WFC</span></quote-block>
</article>
<!-- Application B: the consumer's layout shell is a query container. -->
<div style="container-type: inline-size; width: 1200px">
<aside style="width: 320px">
<quote-block>Encapsulation is a contract, not a wall.<span slot="source">WFC</span></quote-block>
</aside>
</div>
In application A the units have no query container to resolve against, so 5cqi falls back to 5% of the small viewport inline size and the quote scales with the browser window. In application B it resolves against the consumer’s 1200px shell — so a quote sitting in a 320px sidebar renders at 60px, four times too large. The component’s own width, the only number the author cared about, never enters either calculation.
Root-Cause Analysis
CSS Containment Module Level 3 defines the container query length units as percentages of the query container for the element on which they are used. Resolution follows the same lookup as an unnamed @container condition: walk up the ancestor chain from the element, and take the first eligible query container. Eligibility depends on the axis — cqi and cqw need a container with inline-size containment, cqb and cqh need size containment in the block axis.
Two details make the behaviour surprising in a component context.
The lookup crosses shadow boundaries. Containment lives in the rendering tree, not the DOM tree, so an ancestor in the consumer’s light DOM is a perfectly valid query container for an element inside your shadow tree. That is what makes application B resolve against the consumer’s shell. It is not a leak in encapsulation — style rules still do not cross the boundary — but the layout context always did, and container units are a layout concept.
The fallback is the small viewport. The specification is explicit: when no eligible container exists, the units resolve against the small viewport size, exactly as svw and svh would. So the units never fail loudly; they silently become viewport units, which is application A.
Put together, this means container units inside a shadow tree have no defined meaning unless the component supplies its own container. The unit is not scoped to the component by virtue of being written inside it.
@container my-name (…), and authors reasonably assume the units follow the same name. They do not. There is no named form of cqi: the units always resolve against the nearest eligible ancestor container, whatever it is called. A component that names its container but does not own the nearest one will still pick up someone else's box.
The corollary is reassuring: a component that declares container-type: inline-size on :host makes itself the nearest eligible ancestor for everything in its shadow tree. The lookup terminates at the host, and cqi means exactly what the author intended. That single declaration converts the units from ambient to scoped.
Note also what happens with nesting. If a component’s shadow tree contains an inner element that also declares a container, elements below that inner element resolve against it instead. That is usually desirable — a card’s footer sizing against the footer’s own box — but it means the container closest to the element wins, not the host.
Production-Safe Fix
class QuoteBlock extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host {
display: block;
/* Makes the host the nearest eligible container, so every cqi below
resolves against this component's own inline size. */
container: wfc-quote / inline-size;
}
/* clamp() keeps the fluid middle term from producing absurd extremes
when a consumer gives the component an unusual amount of room. */
blockquote {
margin: 0;
font-size: clamp(1rem, 5cqi, 2.5rem);
line-height: 1.35;
}
cite {
display: block;
margin-top: 0.5rem;
font-size: clamp(0.8rem, 2cqi, 1rem);
font-style: normal;
opacity: 0.8;
}
/* An inner container: the pull-quote's own box, not the host's. */
.aside { container: wfc-quote-aside / inline-size; margin-top: 0.75rem; }
.aside .note { font-size: clamp(0.75rem, 4cqi, 0.95rem); }
</style>
<blockquote><slot></slot></blockquote>
<cite><slot name="source"></slot></cite>
<div class="aside"><p class="note"><slot name="note"></slot></p></div>`;
}
}
customElements.define('quote-block', QuoteBlock);
Three things are load-bearing. container-type: inline-size on :host terminates the ancestor walk inside the component, which is the whole fix. clamp() around every fluid value bounds the result, so a consumer who stretches the component to 2000px does not get 100px type — fluid units are a ratio, and a ratio needs limits. And the .aside block demonstrates the nesting rule deliberately: 4cqi inside .note resolves against .aside, not the host, because .aside is now the nearest eligible container.
The width: fit-content caveat from making a host a query container applies here too. If the component was previously inline-sized by its content, add it back.
Verification
const quote = document.querySelector('quote-block');
const block = quote.shadowRoot.querySelector('blockquote');
// The host really terminates the lookup.
console.assert(getComputedStyle(quote).containerType === 'inline-size', 'host is a container');
// The computed size tracks the component, not the consumer's shell.
quote.style.width = '320px';
const atNarrow = parseFloat(getComputedStyle(block).fontSize);
quote.style.width = '640px';
const atWide = parseFloat(getComputedStyle(block).fontSize);
console.assert(Math.abs(atWide - atNarrow * 2) < 1, '5cqi doubles when the component doubles');
// clamp() bounds hold at the extremes.
quote.style.width = '2000px';
console.assert(parseFloat(getComputedStyle(block).fontSize) === 40, 'upper clamp holds');
quote.style.width = '120px';
console.assert(parseFloat(getComputedStyle(block).fontSize) === 16, 'lower clamp holds');
The decisive manual check takes ten seconds: wrap the component in a <div style="container-type: inline-size; width: 1200px"> and confirm nothing changes. Before the fix, the type jumps; after it, the consumer’s container is invisible to the component. Chromium’s Elements panel also shows a container badge on every query container, so counting badges between the styled element and the root tells you immediately which box a unit will use.
When to Use vs When to Avoid
| Goal | Container units | Percentages | Viewport units |
|---|---|---|---|
| Type that scales with component width | cqi with clamp() |
No — percentages do not apply to font-size |
No — ignores the component |
| Padding proportional to component width | cqi or a percentage |
Percentage is simpler and needs no container | No |
| Square media inside a variable box | cqmin |
aspect-ratio is better |
No |
| Full-bleed hero sized to the screen | No | No | svw / dvh are correct |
| Gaps in a grid inside the component | Fixed rem values |
No | No |
| Value that must not vary at all | rem |
No | No |
The middle rows are the ones worth internalising. Container units are not a general replacement for percentages: a percentage width already resolves against the containing block and costs no containment. Reach for cqi specifically when the property does not accept percentages — font-size, border-radius on a fluid card, letter-spacing — or when the reference box you need is a container further up than the containing block.
Frequently Asked Questions
What does cqi resolve to when there is no container at all?
The small viewport inline size, exactly as svw would. The units never throw and never fall back to the element’s own size, which is why the failure is silent and looks like “my component scales with the browser window”.
Can I bind container units to a named container?
No. Names apply to @container conditions only; the units always resolve against the nearest eligible ancestor container. To control what they resolve against, control which element is nearest — normally by declaring the container on :host.
Do container units cross the shadow boundary?
Yes. Containment is part of the rendering tree, so the ancestor walk continues into the consumer’s document. Style rules do not cross the boundary, but layout context always has, and container units are a layout feature.
Why wrap every fluid value in clamp()?
Because a ratio without bounds produces absurd values at the extremes. A component given 2000px of width would render 100px body text from 5cqi. clamp() states the minimum and maximum the design actually supports.
Related
- Container Queries in Components — the parent topic on containment and query resolution.
- Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Making a Host a Query Container — the declaration that scopes these units.
- Container Queries vs Media Queries for Components — choosing the right reference box.
- Implementing Design Tokens with CSS Custom Properties — where fixed values belong instead.