CSS Container Queries Deep Dive: Named Containers, cqi Units, and Style Queries
For a decade, responsive design meant one thing: media queries tied to the viewport. That model works well for page-level decisions — a header, a nav, a global column count — but it quietly breaks down the moment a component gets reused somewhere its author didn't plan for. Drop a card designed for a wide main column into a narrow sidebar, and a viewport-based breakpoint has no way to know the difference. It fires (or doesn't) based on the window, while the actual problem is the box the component happens to be sitting in.
Container Queries (@container) close that gap. They let an element respond to the size of a containing
ancestor — or, with style queries, to a custom property set on one — instead of the viewport, which is what makes
truly modular, drop-in-anywhere components possible.
In this guide, we'll build up from a first working example to the parts that matter in a real design system:
- A complete first container query, from markup to matching rule
- The difference between
inline-sizeandsizecontainment, and the collapse pitfall each one has - How to write the query itself: range syntax,
and/or/not, and which features you can test - Named containers and the
containershorthand, for when a component needs to skip past its nearest ancestor - Container query units (
cqi,cqb,cqmin,cqmax) for fluid, container-relative sizing @container style()— querying a custom property instead of a dimension- A case study on simplifying inline SVG icons by container, plus an interactive playground
- Common pitfalls, debugging in DevTools, best practices, browser support, and safe fallbacks
Quick Refresher: Your First Container Query
A container query needs three pieces: an element that opts in to being measured, a child inside it, and an
@container rule describing how that child should change once the container crosses a size.
<div class="card-wrapper">
<article class="card">
<img class="card__image" src="cover.jpg" alt="" />
<div class="card__body">
<h3>Card title</h3>
<p>Some supporting text.</p>
</div>
</article>
</div>/* 1. Opt in: this element can now be measured by queries inside it */
.card-wrapper {
container-type: inline-size;
}
/* 2. Default: image stacked above the text, which suits narrow spaces */
.card {
display: grid;
gap: 1rem;
}
/* 3. Once .card-wrapper is at least 400px wide, put image and text side by side */
@container (min-width: 400px) {
.card {
grid-template-columns: 1fr 2fr;
}
}When the browser lays out .card, it looks up through its ancestors for the nearest one with a container-type,
finds .card-wrapper, and checks that element's width against min-width: 400px. The viewport never enters into
it: put the same markup in a 300px sidebar and a 900px main column on the same page, and each card picks its own
layout.
Part 3 of the CSS Grid Fundamentals series walks through this same pattern inside a grid of cards. Everything from here on builds on it.
The Core Logic: Containment Contexts
Before a child can ask "how big is my parent?", the parent has to explicitly opt in to being asked. Nothing is a
query container by default — you declare one with container-type, and only then does @container inside it
have anything to resolve against.
.card-wrapper {
container-type: inline-size;
}There are two values worth knowing, and they watch different things:
inline-size(the one you'll use almost every time) — watches only the container's own inline dimension (width, in a standard horizontal writing mode). Height stays completely free to grow with content, exactly like it always has.size— watches both the inline and block dimensions (width and height).
Visual Illustration
The diagram below puts the two values side by side. The arrows mark the axes each one watches: inline-size on
the left has a single horizontal arrow, so only its width is measured, while size on the right has arrows on both
axes. The size panel's dashed outline is the warning above made visible — without a height you set yourself, that
box has nothing to report and collapses.
In practice, that warning is also the whole reason inline-size is the default reach for nearly every real
component. A card, a sidebar widget, a product tile — none of them have a height you'd want to hard-code, so
inline-size lets width-based rules do their job while leaving height exactly as flexible as plain CSS already
makes it.
inline-size has a smaller version of the same trap, though. Its width can no longer come from its content, so an
element whose width normally does come from its content — a float, an inline-block, an absolutely positioned
box, or a flex item that isn't set to grow — collapses to zero width once it becomes a container. Block-level
elements and grid items that fill their track are unaffected, which is why the problem rarely shows up until a
container lands somewhere it has to shrink-wrap. Common Pitfalls covers the fix.
One side effect of any container-type other than normal: it also makes the element a new stacking context, so a
dropdown or tooltip inside a container can't rise above content outside it.
Mastering CSS z-index explains why, and
the other properties that do the same.
Writing the Query: Conditions and Range Syntax
So far every example has used min-width, but the condition inside @container accepts the same kind of size
tests as a media query. The newer range syntax reads more naturally and avoids the off-by-one confusion of
min-/max-:
/* Range syntax — same meaning as (min-width: 400px) */
@container (width >= 400px) { ... }
/* Both ends in one condition */
@container (400px <= width < 700px) { ... }
/* Combine conditions with and / or / not */
@container (width >= 400px) and (orientation: landscape) { ... }
@container (width < 300px) or (height < 200px) { ... }
@container not (width >= 400px) { ... }The features you can test are all about the container's box:
| Feature | Tests | Needs |
|---|---|---|
width, inline-size | The container's width (inline size) | inline-size or size |
height, block-size | The container's height (block size) | size |
aspect-ratio | Width divided by height | size |
orientation | portrait or landscape | size |
The Needs column is the part to remember: anything involving height only works on a container-type: size
container, because an inline-size container doesn't measure its height at all. A height query against an
inline-size container never matches.
Advanced Technique: Named Containers
By default, @container resolves against the nearest ancestor that has a container-type set — full stop. That
works right up until you have containers nested inside other containers, and a deeply nested component needs to
respond to an outer box's width instead of the one immediately wrapping it.
container-name solves this by giving a container an address you can query directly, skipping past anything
nearer:
.grandparent {
container-type: inline-size;
container-name: layout-grid;
}
.parent {
container-type: inline-size;
container-name: component-wrapper;
}
/* Specifically targets .grandparent, bypassing .parent even though it's nearer */
@container layout-grid (min-width: 800px) {
.child {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
}Visual Illustration
The diagram below shows those three elements nested inside each other, with two possible queries written on
.child. The dashed arrow is a plain @container (...) with no name: it stops at the first container it finds,
.parent. The solid arrow is @container layout-grid (...): it passes straight over .parent, because that
name doesn't match, and lands on .grandparent.
This is the one concept in this post that's easy to nod along to and then get wrong the first time you actually
need it, because the failure mode is silent: an @container rule with a name that doesn't match any ancestor
container just never matches, with no warning in the console. If a rule you wrote seems to do nothing, check that
the name in @container <name> (...) is spelled exactly like some ancestor's container-name — and that the
ancestor in question actually has a container-type set at all, not just a name.
This is exactly the pattern behind complex dashboards: a widget nested inside a column, inside a section, inside a top-level grid, where the widget occasionally needs to know about the grid's width specifically — not the column it happens to be sitting in that week.
The container Shorthand
Most real code sets both properties in one declaration with the container shorthand — name first, then a
slash, then the type:
.grandparent {
container: layout-grid / inline-size;
}
/* An element can carry several names, and a query can use any of them */
.sidebar {
container: sidebar panel / inline-size;
}Leaving out the / inline-size part sets container-type back to normal, which means the element has a name but
is no longer a size container — so a named size query against it never matches. If you only want to add a name,
set container-name on its own instead of using the shorthand.
Container Query Units: the cq* Family
Container units are the fluid-sizing counterpart to container queries themselves. They behave like vw/vh, but
instead of measuring the viewport, they measure the nearest query container — which means a value written once in
cqi stays proportional no matter where that component ends up living.
| Unit | Measures |
|---|---|
cqi | 1% of the container's inline size (width, in a standard writing mode) |
cqb | 1% of the container's block size (height) |
cqmin | Whichever of cqi / cqb is smaller |
cqmax | Whichever of cqi / cqb is larger |
"Inline" and "block" here follow the container's writing mode, not the screen: in a vertical-rl container, cqi
measures height. CSS Logical Properties covers
how those two axes map onto width and height.
Visual Illustration
The diagram below measures one container box with both units. The arrow along the bottom is 100cqi, the box's
full width; the arrow up the right side is 100cqb, its full height. Notice the box is drawn wider than it is tall
— that's what decides the two derived units in the legend: cqmin picks the smaller axis, so here it equals cqb,
and cqmax picks the larger, so here it equals cqi.
cqmin and cqmax trip people up at first because they don't measure a dimension of their own — they're just a
shorthand for "whichever axis is smaller/larger right now". Turn the box in the diagram into a tall, narrow one and
the two would swap. Most of the time, the unit you actually reach for is cqi:
.hero-banner__title {
/* Always 10% of THIS banner's own width, wherever it's placed */
font-size: 10cqi;
}A bare 10cqi has no floor, no ceiling, and no rem term, so it ignores the reader's font-size and zoom settings.
In production, wrap it in clamp() with a rem component, such as clamp(1.5rem, 1rem + 6cqi, 3.5rem).
Fluid Typography and Spacing with clamp()
walks through building that formula.
The Frontier: Style Queries
Everything so far queries size. @container style() queries something different entirely: a computed CSS
custom property value on the container, regardless of its dimensions.
.card-wrapper {
--theme: dark;
}
/* Matches based on the VALUE of --theme, not the wrapper's size */
@container style(--theme: dark) {
.card {
background: #121212;
color: white;
border: 1px solid #333;
}
}Unlike a size query, a style query doesn't need container-type at all. Every element can already be queried
for its styles, so an unnamed style() query simply checks the nearest ancestor — usually the card's parent.
Visual Illustration
The diagram below shows two cards that are exactly the same size. The only difference is the value of --theme
set on each one's container: light on the left, dark on the right. The query at the top, style(--theme: dark),
matches only the right-hand card — size plays no part in the decision, only the custom property's value does.
This turns a component's aesthetic, not just its layout, into something that adapts to context. A single card
component can pick up a light or dark treatment purely from a --theme variable set somewhere above it — no theme
class, no JavaScript, no prop threading through a component tree. Set the variable once at whatever level makes
sense (a section, a whole page, a single card slot) and every style-query rule below it resolves independently.
If you only need to switch a single value rather than a whole rule, the
if() function uses the same style() test inside one declaration.
Case Study: Simplifying Inline SVG Icons by Container
Container queries aren't only for text and grid columns — they're just as useful wrapped around an inline SVG. The trick is the same one used everywhere else in this post: instead of asking "how big is the screen?", ask "how big is the box this icon actually got rendered into?"
.icon-wrapper {
container-type: inline-size;
}
/* Below 50px, drop the fine detail entirely rather than let it turn to noise */
@container (max-width: 50px) {
.complex-logo .detailed-paths {
display: none;
}
}A logo icon dropped into a dense toolbar at 24px and the same icon used as a 200px hero mark are the same file,
but they don't need the same amount of detail — past a certain size, fine paths just render as a blurry smudge
instead of the extra polish they're meant to be. Wrapping the <svg> in a container lets the icon simplify itself
below that threshold, on a 4K monitor exactly as reliably as on a phone, because the decision is about the icon's
own rendered box, not the display it happens to be on.
Note that .icon-wrapper needs a width from its surroundings — a fixed size, or a grid or flex track that sizes it
— because, as an inline-size container, it can't take its width from the icon inside it.
Interactive Playground
Reading about a breakpoint tied to a container instead of the viewport is one thing; feeling it is another. The
playground below has three tabs: Size Queries drags a card's own container past a breakpoint while its title
scales live in cqi; Named Containers keeps one fixed @container rule on a card but lets you retarget which
of two nested, independently resizable containers it actually measures; and Style Queries flips a --theme
custom property and watches @container style(--theme: dark) match or stop matching in real time.
Container Queries Playground
Drag a container past a breakpoint while its title scales in cqi, retarget a named @container query between two nested containers, and flip a --theme custom property to trigger a live style query.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Container Queries Playground</title> </head> <body> <main class="playground"> <header class="playground-header"> <h1>Container Queries Playground</h1> <p>Drag containers, retarget a named query, and flip a themed custom property — all three panels below show the generated CSS live.</p> </header> <div class="tabs" role="tablist" aria-label="Playground mode" id="mode-switch"> <button class="tab tab--active" type="button" data-mode="size" role="tab" aria-selected="true">Size Queries</button> <button class="tab" type="button" data-mode="named" role="tab" aria-selected="false">Named Containers</button> <button class="tab" type="button" data-mode="style" role="tab" aria-selected="false">Style Queries</button> </div> <div id="size-demo" class="tab-panel tab-panel--active"> <section class="demo-area" aria-label="Size query card preview"> <div id="size-container" class="size-container"> <div class="size-card"> <h3 id="size-card-title" class="size-card-title">Dashboard Widget</h3> <div class="size-card-meta">Updated 5 mins ago</div> <div class="size-card-body">This card's layout AND its title size respond to the container's own width — resize it and the viewport never enters the equation.</div> </div> </div> </section> <output id="size-output" class="readout" aria-live="polite"></output> <fieldset class="control-group"> <legend>@container</legend> <div class="fields-grid"> <div class="field"> <label for="size-width-input">Container width</label> <div class="range-row"> <span class="range-edge">220px</span> <div class="range-track"> <input type="range" id="size-width-input" min="220" max="720" step="10" value="340"> <output class="range-bubble" id="size-width-value" for="size-width-input">340px</output> </div> <span class="range-edge">720px</span> </div> </div> <div class="field"> <label for="size-breakpoint-input">@container min-width</label> <div class="range-row"> <span class="range-edge">220px</span> <div class="range-track"> <input type="range" id="size-breakpoint-input" min="220" max="720" step="10" value="480"> <output class="range-bubble" id="size-breakpoint-value" for="size-breakpoint-input">480px</output> </div> <span class="range-edge">720px</span> </div> </div> <div class="field"> <label for="size-font-input">Title size <span>(cqi)</span></label> <div class="range-row"> <span class="range-edge">4cqi</span> <div class="range-track"> <input type="range" id="size-font-input" min="4" max="12" step="0.5" value="7"> <output class="range-bubble" id="size-font-value" for="size-font-input">7cqi</output> </div> <span class="range-edge">12cqi</span> </div> </div> </div> <p class="tip">Drag the container past the breakpoint to switch from a stacked to a side-by-side layout, then drag the <code>cqi</code> slider — the title scales with the CONTAINER's width, so it stays proportional at every size instead of needing its own breakpoint.</p> </fieldset> </div> <div id="named-demo" class="tab-panel"> <section class="demo-area" aria-label="Named container preview"> <div id="outer-container" class="outer-container"> <span class="container-tag">.layout-grid</span> <div id="inner-container" class="inner-container"> <span class="container-tag container-tag--inner">.component-wrapper</span> <div id="named-card" class="named-card"> <div class="named-card-title">Card</div> <div class="named-card-body">Reflows at 400px — but 400px of <em>which</em> container?</div> </div> </div> </div> </section> <output id="named-output" class="readout" aria-live="polite"></output> <fieldset class="control-group"> <legend>@container name</legend> <div class="segmented-control" role="radiogroup" aria-label="Query target"> <label class="segment"> <input type="radio" name="named-target" value="component-wrapper" checked> <span>component-wrapper (nearest)</span> </label> <label class="segment"> <input type="radio" name="named-target" value="layout-grid"> <span>layout-grid (named)</span> </label> </div> <div class="fields-grid"> <div class="field"> <label for="outer-width-input">.layout-grid width</label> <div class="range-row"> <span class="range-edge">300px</span> <div class="range-track"> <input type="range" id="outer-width-input" min="300" max="700" step="10" value="450"> <output class="range-bubble" id="outer-width-value" for="outer-width-input">450px</output> </div> <span class="range-edge">700px</span> </div> </div> <div class="field"> <label for="inner-width-input">.component-wrapper width</label> <div class="range-row"> <span class="range-edge">200px</span> <div class="range-track"> <input type="range" id="inner-width-input" min="200" max="500" step="10" value="360"> <output class="range-bubble" id="inner-width-value" for="inner-width-input">360px</output> </div> <span class="range-edge">500px</span> </div> </div> </div> <p class="tip">The card's <code>@container (min-width: 400px)</code> rule never changes — only which named ancestor it targets does. Switch targets without touching either slider and watch the same card flip layout, because it's now measuring the other box's width.</p> </fieldset> </div> <div id="style-demo" class="tab-panel"> <section class="demo-area" aria-label="Style query card preview"> <div id="style-container" class="style-container"> <div id="style-card" class="style-card"> <div class="style-card-title">Themed Card</div> <div class="style-card-body">This card's colours come entirely from <code>@container style(--theme: dark)</code> — no class toggle, no JS-applied theme class.</div> </div> </div> </section> <output id="style-output" class="readout" aria-live="polite"></output> <fieldset class="control-group"> <legend>--theme</legend> <div class="segmented-control" role="radiogroup" aria-label="Container theme"> <label class="segment"> <input type="radio" name="theme" value="light" checked> <span>light</span> </label> <label class="segment"> <input type="radio" name="theme" value="dark"> <span>dark</span> </label> </div> <p class="tip">Flipping this only ever changes one custom property on the container above — <code>.style-card</code> itself has no theme class of its own. The style query reads that property's computed value directly.</p> </fieldset> </div> </main> <script src="./index.js"></script> </body> </html>
Starting sandbox…
No console output yet.
No original version of /index.html to compare against.
Common Pitfalls
1. Styling the Container Itself
An @container rule can only style descendants of the container it measures. Writing
@container (width >= 400px) { .card-wrapper { ... } } where .card-wrapper is the container does nothing. Move
the styles to a child, or add a wrapper element and make that the container.
2. A Container That Collapses to Zero Width
container-type: inline-size stops an element taking its width from its content. On anything that normally
shrink-wraps — a float, an inline-block, an absolutely positioned element, a flex item with flex: none — the
container collapses to zero width and everything inside overflows or disappears. Give it a width from outside: set
inline-size: 100% or a fixed width, let the flex item grow with flex: 1, or move container-type up to a parent
that already fills its space.
3. A size Container With No Height
The same problem on the other axis: container-type: size without an explicit height collapses the container to
0px tall. Use inline-size unless you genuinely know the height, and remember that height, aspect-ratio, and
orientation queries only work on size containers.
4. Forgetting That Padding Isn't Measured
A container query measures the container's content box — its width minus padding and borders. A wrapper that
takes up 400px on the page with 1rem of padding on each side (as it does under box-sizing: border-box) is only
368px wide as far as @container (width >= 400px) is concerned. You can see this in the playground's Named Containers tab: drag .layout-grid to exactly
400px and the 400px rule still doesn't match, because the box's own padding and border take it under. Account
for padding in the breakpoint, or move the padding onto a child.
5. A Mistyped or Type-Less Container Name
@container layuot-grid (...), or a name on an element with no container-type, simply never matches — there's
no error anywhere. Check the spelling against the ancestor's container-name, and check that the ancestor is a
size container at all. The container shorthand without a / type part causes the same problem (see
The container Shorthand).
6. Container Units With No Container
cqi and friends used outside any container-type ancestor fall back to the small viewport, so a "component-sized"
value quietly turns viewport-sized. If a cq* value looks like it's tracking the window, look for the missing
container.
Debugging Container Queries in DevTools
Because a container query that doesn't match fails silently, DevTools is the quickest way to see which box a rule is measuring:
- Find the containers. In Chrome and Edge, the Elements panel shows a
containerbadge next to every element with acontainer-type. Clicking the badge highlights that element on the page along with the descendants that query it. - Inspect the child, not the container. Select the element your
@containerrule is supposed to style. In the Styles pane, the rule appears under its@containercondition, with a link to the container it resolved against. Hovering that link highlights the container on the page — if it's not the box you expected, the problem is the lookup, not the condition. - Check the size against the content box. In the Computed tab of the container, compare the content-box width (the inner rectangle of the box model diagram) with your breakpoint. This is where padding surprises (pitfall 4) show up.
- Check for a collapsed container. A container showing a width or height of
0in the box model is pitfall 2 or 3.
Firefox's Rules view lists @container rules with their conditions in the same way, and hovering the
condition highlights the container it matched against, so the same workflow applies there.
Best Practices for Container Queries
- Reach for
inline-sizeby default.sizecontainment has a real, easy-to-hit pitfall (the height-collapse warning above) — only use it when the height is genuinely fixed by the surrounding layout, not by content. - Put the container on a wrapper that already fills its space. A block-level wrapper or a grid item that stretches across its track never collapses, so it makes a safer container than the component's own root.
- Name containers as soon as you nest them. The moment a second
container-typeshows up inside another one, addcontainer-nameto both — it costs nothing today and saves a confusing "why didn't this match" moment later. - Prefer
cqifor typography and spacing inside components — wrapped inclamp()with aremterm. It keeps a component visually consistent wherever it's dropped, the same benefitfrandminmax()bring to Grid tracks, without breaking the reader's zoom. - Use style queries for theme/variant switches, not one-off exceptions. They shine when the same component needs to react to a variable set by something else (a section theme, a density setting) — for a single component's own internal state, a plain class or attribute selector is still simpler.
- Don't reach for a container query when a media query is actually correct. Global chrome — the site header, primary nav, overall page column count — genuinely depends on the viewport. Container queries solve the component reused in different-sized boxes problem specifically; using them everywhere just trades one kind of rigidity for a different one.
Browser Support & Fallbacks
Size-based container queries (container-type, container-name, and @container) and the cq* units have been
Baseline since February 2023, so every current major browser supports them. @container style() against custom
properties became Baseline in May 2026 with Firefox 151 — supported in every current browser, but recent enough
that you may still want a fallback for visitors on older versions. Style queries against standard properties
aren't supported yet.
For an older browser or a progressively enhanced layout, wrap the enhancement in a feature query so unsupported browsers just get your fallback instead of a rule that silently never applies:
/* Fallback: simple stacked layout, applies everywhere */
.card {
display: grid;
grid-template-columns: 1fr;
}
/* Only registers container-type where @container itself is supported */
@supports (container-type: inline-size) {
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
grid-template-columns: 2fr 1fr;
}
}
}For style queries, the safest fallback is to make the un-themed default look correct on its own. A browser that
doesn't understand @container style() skips the whole block, so the card simply keeps its default theme.
Quick Reference
| Syntax | What it does |
|---|---|
container-type: inline-size | Makes the element a container measured on its width — the usual choice |
container-type: size | Measures width and height; needs an explicit height |
container-name: layout-grid | Gives the container a name that queries can target directly |
container: layout-grid / inline-size | Shorthand for name and type together |
@container (width >= 400px) | Matches against the nearest size container |
@container layout-grid (width >= 400px) | Matches against the nearest ancestor named layout-grid |
@container style(--theme: dark) | Matches on a custom property's value, not a size |
cqi / cqb | 1% of the container's inline / block size |
cqmin / cqmax | The smaller / larger of cqi and cqb |
@supports (container-type: inline-size) | Feature-detects container query support for a fallback |
Frequently Asked Questions
Conclusion
Media queries answer "how big is the screen?" — a question that made perfect sense when we designed pages, and still does for genuinely global layout decisions. Container queries answer a different, more honest question for everything else: "how big is the box I'm actually sitting in?" Named containers extend that question past the nearest ancestor when a component needs to reach further; container units make sizing inside a component scale with it automatically; and style queries extend the whole idea from size to appearance.
Put together, these four pieces are what turn a component from "works in the layout I originally tested it in" into something that genuinely doesn't care where it's dropped — a sidebar, a wide main column, a dense dashboard tile, or a hero banner, all from the same markup and the same CSS.
What's Next: From Static Layout to Motion
Container queries solve what a component looks like at a given size or theme. The next natural question is how it gets there — what happens in the moment a card actually crosses that breakpoint, rather than snapping instantly from one layout to the other.
In an upcoming post for the Animations category, we'll explore:
- Transitioning layout-affecting properties across a container query breakpoint, not just colour and opacity
@starting-stylefor animating an element the instant it's inserted into a newly-matched container- Combining container queries with view transitions for a genuinely fluid reflow
That deep dive is currently in the works — stay tuned to the Animations category for the update!
This one is built around four diagrams and a three-tab sandbox: two containment panels that make the size-collapse pitfall impossible to miss, nested boxes where one arrow has to visibly skip past the nearer container, a single box showing cqmin and cqmax borrowing their values from cqi and cqb, and two identical cards that only a custom property tells apart. The named-container diagram took the most redrawing — an arrow that skips past a box can very easily look like one pointing at the wrong thing. The sandbox's middle tab is the one I'm proudest of: the same @container rule, the same 400px breakpoint, and a single radio button alone decides which box gets measured. Getting every tab's live CSS readout to match what the browser actually applies took more back-and-forth than I'd like to admit. I hope the next time a component looks wrong in a sidebar, you ask which box it's measuring before reaching for another media query. Thanks for containing yourself with me. 📦