You built a card that looks right in the main column and wrong in the sidebar. Same component, same markup, two very different widths — and the media query you wrote can’t tell the two apart, because it only knows how wide the window is, and the window is identical in both places.
So you add a prop. variant="compact". Then a second one for the dashboard
tile, and a third for the drawer, and now one component has four layouts that
somebody has to keep in sync forever.
Container queries end that. They don’t work the way most people assume on first contact, though, and the thing that goes wrong first is structural: the element you want to query is never the element you can query. Get that part right and the rest of the feature is small.
The Viewport Is The Wrong Question
A media query asks the browser exactly one thing — how big is the window. That’s a good question when you’re deciding whether the page has one column or two. It’s the wrong question for a card, because the card doesn’t live in the window. It lives in whatever box it landed in, and that box might be 900px wide on one route and 280px wide on another.
The usual workaround hoists the decision up to the call site.
export function ArticleList({ post }) {
return (
<div className="page">
<main>
<ArticleCard post={post} />
</main>
<aside>
<ArticleCard post={post} variant="compact" />
</aside>
</div>
);
}
That holds up right until it doesn’t. The variant prop encodes a layout fact
— how much room there is — in a place that has no way to measure it. Widen the
sidebar from 280px to 420px and nothing updates, because nothing was ever
measuring. Drop the compact card into a wide drawer and it stays compact out
of loyalty to a decision made somewhere else.
The variants also multiply. Compact, wide, tile, inline. Four layouts, four sets of styles, and no rule that tells you which one is correct in a slot nobody has tried yet.
What you want is for the card to ask its own question: how much room did I get? That’s the question container queries answer.
A Container Is Not Itself
Here’s the part everyone gets wrong once. container-type does not make an
element queryable. It makes that element a query container
for its descendants.
So this does nothing at all:
/* the query never sees this element */
.card {
container-type: inline-size;
display: grid;
}
@container (min-width: 30rem) {
.card {
grid-template-columns: 12rem 1fr;
}
}
The @container rule goes looking for the nearest ancestor query container
of .card. An element isn’t its own ancestor, so the lookup walks straight
past it. If nothing further up is a container, the rule never matches — and
because every declaration involved is perfectly valid, there’s nothing for
DevTools to strike through.
The fix is a wrapper whose entire job is to be measured.
.card-slot {
container-type: inline-size;
}
.card {
display: grid;
gap: 1rem;
}
@container (min-width: 30rem) {
.card {
grid-template-columns: 12rem 1fr;
}
}
This split isn’t an oversight in the spec, it’s the whole design. If an element could restyle itself based on its own measured width, and that restyling changed its width, the browser would have to pick which of two answers is true. Layout would either loop or need an arbitrary cutoff. Separating the roles — one box gets measured, a different box responds — keeps resolution one-directional and cheap.
The measured box and the responding box are never the same box.
— Internal_Style_Doctrine // §2.4
In practice the wrapper is usually already sitting there: the grid item, the
list li, the slot your design system hands you. Put container-type on that
and leave the component’s own styles alone.
Containment Is The Price
container-type: inline-size isn’t only a label. It switches on layout
containment, style containment, and inline-size containment
for that element. The last one is the one that bites: the element’s inline size
now has to be computable without looking at its contents.
For an ordinary block in normal flow, that costs nothing. Its width already came down from its parent. But shrink-to-fit boxes get their width from their contents, and containment takes that source away.
Breaks
The slot is a flex item that shrink-wraps its content. Containment removes the content from that calculation, so the box measures zero and every query reports the smallest state.
Holds
The same slot takes its width from outside — width: 100%, a sized grid track, or plain block-level flow — so containment changes nothing visible.
The rule to carry around: a query container needs its width to arrive from
outside. Floats, inline-blocks, table cells, and items in auto tracks all
fail that test, and they fail it silently by collapsing rather than erroring.
!
container-type: size contains both axes.
Height stops coming from content too, so most elements collapse to nothing. Use it only where you’ve already set an explicit block size.
Style containment has a quieter effect worth knowing about: counters and quotes
are scoped to the container. A counter-increment that used to run across the
whole page will restart inside each one.
The Nearest Container Wins
An unnamed @container query resolves against the nearest ancestor that is a
query container. Nearest, not outermost. Nest one container inside another and
the inner one shadows the outer for everything below it — fine until a card
slot sits inside a region slot and your card starts answering to the wrong box.
Names fix that.
.page-region {
container: region / inline-size;
}
.card-slot {
container: card / inline-size;
}
@container card (min-width: 30rem) {
.card {
grid-template-columns: 12rem 1fr;
}
}
container: <name> / <type> is the shorthand for container-name plus
container-type. Names aren’t unique identifiers — any number of elements can
share one, and the query still picks the nearest matching ancestor. That’s the
useful part: name every card slot card, and the component works wherever it
lands.
One asymmetry is worth planning around. If no ancestor container exists at all,
an @container rule simply never matches; there’s no fallback to the viewport.
A component built entirely out of container queries will render in its smallest
state the moment someone drops it outside a container, so make that unqueried
state the sensible default rather than the broken one.
| Symptom | Likely cause | Check |
|---|---|---|
| Rule never applies | container-type is on the queried element | Move it to a wrapper |
| Container measures zero | Shrink-to-fit box under containment | Give it width from outside |
| Responds to the wrong box | A closer ancestor is also a container | Name it and query by name |
| Nothing changes anywhere | No ancestor container exists | Walk the chain for container-type |
Units That Track The Container
Alongside the at-rule you get units measured against the same container: cqi
and cqb for the inline and block axes, cqw and cqh for width and height,
plus cqmin and cqmax. One cqi is 1% of the query container’s inline size.
These are often the better tool, and they’re underused. A breakpoint is a step function, but most of what you want inside a component is continuous.
.card__title {
font-size: clamp(1.05rem, 0.9rem + 1.4cqi, 1.6rem);
}
That title grows with the slot it’s in and stops at both ends. No breakpoint, no variant, and it behaves sensibly in a 280px rail and a 700px column without you enumerating either case.
The container lookup is the same one the at-rule uses — nearest ancestor query
container, matching the name if you gave one. What happens when there isn’t one
differs, though: the spec says to
fall back to the small viewport size for that axis, so cqi in an uncontained
component behaves roughly like svw. Confirm that in the browsers you support
before you lean on it.
My rough split: units for type, gaps, and padding; the at-rule when the layout
genuinely changes shape, like a row becoming a column or an element appearing.
Reaching for @container to nudge a font size is how you end up with six
breakpoints inside a single component.
Where Media Queries Still Belong
Container queries didn’t retire media queries. They took one job away from them, and the split reads cleanly once you name it: a media query is about the environment, a container query is about the space.
If the answer changes because the user is printing, or prefers reduced motion, or is using a coarse pointer, or the device is genuinely tiny — media query. If it changes because you moved the same component from the main column to the sidebar — container query.
Three things to do to an existing component:
- Find every viewport breakpoint inside it. Most are describing available width rather than device width, and those convert directly.
- Add the wrapper before converting anything. The component keeps its own styles; the slot becomes the container.
- Delete the
variantprops those breakpoints were standing in for. If a prop exists only to mean “small”, the container already knows.
Size container queries have shipped in every major browser since early 2023, so
this is ordinary CSS now rather than a progressive enhancement. Style queries —
the style() form that tests a custom property — are newer and less evenly
supported, so check before you ship one.
The syntax isn’t the part worth keeping. It’s that a component can finally be told how much room it has, instead of being told what to look like — which is the version of a design system that survives being rearranged.