Container Queries in CSS: Components That Finally Respond to Their Own Space

Container Queries in CSS: Components That Finally Respond to Their Own Space

Responsive CSS has always meant asking the viewport one question: how wide are you? Media queries answered it well enough to build an entire era of layout practice on top of them. But the question itself was never quite right for components. The viewport is a property of the browser window, not of the card, widget, or form section you are actually trying to style.

Real pages rarely hand every component the full viewport. The same card ends up in a main content column, a 320-pixel sidebar, a modal dialog, a portal widget, or a webview pane embedded in someone else’s application — a mail client, an admin portal, a desktop app shell. The media query sees a 1440-pixel window in every one of those scenarios. The card in the sidebar sees a sliver of it, and your carefully chosen breakpoint knows nothing about that.

Container queries change the question. Instead of asking what the browser window looks like, a component asks how much space it actually occupies, and adapts to that. This post walks through how containment makes that possible, how to write the queries, how to name containers for nested layouts, the units that arrive with them, and what to do about the browsers still left behind.

Why Media Queries Break Reusable Components

A media query couples a component to a page-level assumption. Here is the pattern most of us have written, a card that switches to a horizontal layout once the screen gets wide:

/* Component CSS from a simpler era */
.card {
  display: grid;
  gap: 1rem;
}

@media (min-width: 60rem) {
  /* "Desktop" — assume the card has room to spread out */
  .card {
    grid-template-columns: 220px 1fr;
    align-items: start;
  }
}

On a wide window that rule fires and the card goes horizontal. Drop the same card into a right-hand sidebar on that very same window and the rule still fires — the viewport has not changed — but the card now has 300 pixels to live in. The result is a crushed two-column layout nobody designed.

The failure has nothing to do with the breakpoint value. The unit of measurement is wrong. A component’s layout should depend on the space it is given, not the space the window happens to have. That mismatch shows up wherever viewport width and available width diverge:

  • Sidebars and split views, where one component sits next to a fixed or fluid column.
  • Modals, drawers, and portal widgets, which are narrow regardless of the screen behind them.
  • Embedded webviews — a pane inside a mail client or an admin portal has its own geometry entirely.
  • Design systems, where the component author cannot know every placement a page will invent.

The component author does not know where the component will live; the page author should not have to reach into the component’s internals to fix it. Media queries offer no clean split of responsibility here. Container queries do.

container-type, Containment, and Your First Query

To query a box, the browser has to know that box’s size without laying out its contents. Otherwise a query could change the very size that triggered it, and you would have a layout loop. CSS solves this with containment: a promise that the container’s dimensions are computed independently of what is inside it.

container-type: inline-size applies layout, style, and inline-size containment. The element’s inline size (width, in horizontal writing modes) must come from the outside — a grid track, a flex basis, a percentage width — while its height still derives from its content. That is the sweet spot for almost every real component, because you usually want the box to grow vertically with what is in it.

container-type: size applies full size containment: both axes are independent of content. Unless you give the element an explicit height or an aspect-ratio, its block size resolves to zero, which is the classic first-day container query bug. Reserve size for boxes whose height is already pinned down — fixed panels, media frames — and note that height-based queries only work against size containers.

One more gotcha before the first query: an element can never respond to a container it creates itself. @container rules style the descendants of the container, so the standard pattern is to put container-type on a wrapper or slot and style what lives inside it:

/* The slot becomes the query container.
   Width comes from the page layout; height from content. */
.card-slot {
  container-type: inline-size;
}

/* "Does the nearest ancestor container have 30rem of inline space?" */
@container (min-width: 30rem) {
  .card {
    grid-template-columns: 220px 1fr;
    align-items: start;
  }
}

An unnamed query like this resolves against the nearest ancestor with containment applied. That is often all you need, and the @container rule reads almost exactly like the media query it replaces — same condition syntax, different subject. The mental shift is the point: the card no longer cares what device it is on, only how much room it has.

Container-Relative Units and clamp() Typography

Container queries ship with their own units. cqw is 1% of the container’s width and cqh is 1% of its height. The logical companions cqi and cqb map to the container’s inline and block axes, which makes cqi the better default: it keeps working when the writing mode is vertical. One caveat worth internalizing — cqh and cqb only resolve against containers that actually contain that axis, so they need a container-type: size ancestor; otherwise they fall back to small viewport units.

Where these units shine is fluid typography. Paired with clamp(), a heading scales smoothly with the space available while staying inside bounds you control:

/* 1cqi is 1% of the container's inline size */
.card-title {
  font-size: clamp(1.1rem, 5cqi, 1.9rem);
  line-height: 1.2;
}

.card-kicker {
  font-size: clamp(0.8rem, 2.5cqi, 1rem);
  letter-spacing: 0.08em;
}

/* cqh needs a container whose block axis is contained */
.hero {
  container-type: size;
  height: 60vh;
}

.hero-title {
  font-size: clamp(2rem, 9cqh, 4rem);
}

The bounds are not optional decoration. Unbounded fluid type produces unreadably small text in tiny containers and comically large text in wide ones; clamp() is what turns a clever unit into a responsible one.

Named Containers: One Card, Two Contexts

Unnamed queries bind to the nearest ancestor container, and nesting breaks that quickly — a page shell container wrapping a sidebar container wrapping a card makes “nearest” ambiguous. The fix is names: apply container-name (or the shorthand container: card / inline-size) and target it explicitly with @container card (…).

Here is the earlier card rebuilt that way. Identical markup, dropped into a main column and a narrow sidebar; the page grid decides placement, the component reads its slot:

<main class="layout layout--main">
  <article class="card">
    <img class="card-media" src="cover.jpg" alt="">
    <div class="card-body">
      <p class="card-kicker">Architecture</p>
      <h3 class="card-title">Querying the box you live in</h3>
      <p class="card-text">A component that adapts to its slot, not the window.</p>
      <a class="card-link" href="#">Read more</a>
    </div>
  </article>
</main>

<aside class="layout layout--side">
  <!-- same .card markup -->
</aside>

/* The name travels with the containment context */
.layout {
  container: card / inline-size;
}

/* Compact, stacked defaults — no query needed */
.card {
  display: grid;
  gap: 1rem;
}

/* Room for a side-by-side arrangement (main column) */
@container card (min-width: 30rem) {
  .card {
    grid-template-columns: minmax(180px, 240px) 1fr;
    align-items: start;
    gap: 1.5rem;
  }
}

/* Genuinely spacious (full-width hero slot) */
@container card (min-width: 55rem) {
  .card {
    grid-template-columns: minmax(240px, 320px) 1fr;
    gap: 2rem;
  }

  .card-title {
    font-size: clamp(1.6rem, 4cqi, 2.4rem);
  }
}

Notice there are no layout-specific classes on the card itself, no modifier churn between placements, no JavaScript measuring anything. The base styles describe the compact card, and each query opts into progressively roomier arrangements as the slot — not the window — widens. That is the whole promise of container queries delivered in one component.

Style Queries for Token-Driven Variants

Sometimes the signal a component needs is not size but state. style() queries let it ask about a custom property on its container. In current implementations this works for custom properties; querying arbitrary declarations is not broadly supported, so treat custom properties as the interface. The page sets a token, the component branches:

/* The page declares the variant on the container */
.promo-slot {
  container: promo / inline-size;
  --promo-variant: featured;
}

/* The component reacts to the token */
@container promo style(--promo-variant: featured) {
  .promo {
    border: 2px solid var(--accent);
    background: var(--surface-raised);
  }
}

/* Style conditions combine with size conditions */
@container promo (min-width: 30rem) style(--promo-variant: featured) {
  .promo {
    grid-template-columns: 160px 1fr;
  }
}

This keeps variant decisions at the placement level while the component CSS stays the single source of visual truth — a quiet but real win for design systems.

Browser Support and Progressive Enhancement

The core feature is in good shape. Size container queries and the container units shipped in Chrome and Edge 105, Safari 16, and Firefox 110, which means every major engine has had them since early 2023 — the caniuse page has been solidly green for a long time. Style queries are the laggard: Chromium has supported custom-property style queries since 2023, and other engines are catching up, so verify their status before making them load-bearing.

For the long tail of older browsers, the enhancement pattern is source order. Write your base styles and a coarse media-query layout first, then append the @container rules after them. Browsers without support ignore the queries and keep the viewport-driven layout; browsers with support get the container-driven refinement on top. If you want to be explicit, @supports (container-type: inline-size) gives you a clean guard. Either way, media queries become the fallback rather than the primary mechanism — a reversal of the last decade, and a healthy one.

Wrapping Up

Container queries close a gap that has sat open since we started building component-based interfaces. A component can finally own its own responsiveness: the page decides where it goes and how wide that slot is, and the component reads the answer for itself. The ingredients are small — container-type on a wrapper, queries against inline size, names for nested layouts, cqi with clamp() for type, style() for token-driven variants — and they compose with the grid and flexbox you already use.

The durable shift is the mental model. Stop asking “what device is this?” and start asking “what space does this component have?” The first question was always a proxy. The second one is the truth.

Leave a Reply

Your email address will not be published. Required fields are marked *