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.

Which box a cqi unit resolves against in three page structures With no ancestor container the unit falls back to the viewport, with a consumer container it resolves against that consumer box, and only a container on the host resolves against the component itself. Same declaration: font-size: 5cqi No ancestor container viewport (fallback) article 640px quote-block scales with the window component width ignored Consumer declares one shell 1200px — the container aside 320px quote-block 5cqi = 60px four times too large Host is the container shell 1200px aside 320px quote-block 5cqi = 16px portable and predictable

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.

Debugging Pitfall — naming the container does not name the units. Container query conditions can be bound explicitly with @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.

The ancestor walk that resolves a container unit Resolution walks upward from the element through the shadow tree and across the shadow boundary into the consumer document, stopping at the first eligible container or falling back to the small viewport. blockquote { font-size: 5cqi } .wrapper (no container) :host — container-type: inline-size lookup stops here consumer shell (never reached) With a container on :host the walk terminates inside the component cqi means the component's own inline size portable across every consumer layout Without one the walk crosses the shadow boundary and takes whatever the consumer declared or the small viewport if there is nothing

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.

Computed font size across component widths, with and without clamp The raw ratio grows without limit as the component widens, while the clamped curve flattens at sixteen pixels below 320 pixels and at forty pixels above 800 pixels. clamp(1rem, 5cqi, 2.5rem) versus a bare 5cqi 120px 320px 560px 800px 1040px component inline size 0 16px 40px 56px bare 5cqi 52px and still climbing lower clamp holds at 16px upper clamp holds at 40px

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.