Skip to content
Abhishek Sawant

Frontend9 Min Read

Container Queries Need A Container

Why an element can never respond to its own width, what inline-size containment quietly costs you, and how the browser decides which container answers a query.

FIG_01 // FRONTEND
Fig. 01 — Container Queries Need A Container

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.

ArticleList.jsx jsx
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:

card.css css
/* 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.css css
.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.

layout.css css
.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.

SymptomLikely causeCheck
Rule never appliescontainer-type is on the queried elementMove it to a wrapper
Container measures zeroShrink-to-fit box under containmentGive it width from outside
Responds to the wrong boxA closer ancestor is also a containerName it and query by name
Nothing changes anywhereNo ancestor container existsWalk 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.css css
.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:

  1. Find every viewport breakpoint inside it. Most are describing available width rather than device width, and those convert directly.
  2. Add the wrapper before converting anything. The component keeps its own styles; the slot becomes the container.
  3. Delete the variant props 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.

Tagged
  • CSS
  • Layout
  • Design_Systems

Related Logs

System Updates

Weekly dispatches,
straight to your terminal.