Web Development

CSS Container Queries in Practice: A Working Component Example

How we built a single tool card component that reflows differently in a grid versus a sidebar using CSS container queries, including the containment gotcha that broke it at first.

By Aissam Ait Ahmed Web Development 0 comments

The problem media queries can't solve

Media queries respond to the viewport. That's fine until you build a component meant to be reused in more than one layout context — a card that lives in a three-column grid on the tools index page, but also gets dropped into a single narrow sidebar slot on an individual tool's detail page. With only media queries, that card can't know how much space it actually has; it only knows how wide the browser window is. A 1400px-wide viewport tells you nothing about whether the card itself is sitting in a 320px sidebar column or a 460px grid cell. Container queries fix exactly this gap, and once you've built one real component with them, the value is obvious in a way that reading the spec doesn't quite convey.

A real component: the tool card that lives in two places

We rebuilt the card used across onlinetoolspro.net for individual tools — the ones that link out to pages like our URL Encoder Decoder — because the exact same markup needed to look right whether it was one of many cards in a dense grid or the single featured card in a narrow "related tools" rail. Media queries meant picking one layout and hoping it looked acceptable in both places. It didn't; text wrapped awkwardly in the narrow rail, or the icon was oversized in the dense grid.

The markup

<div class="tool-card-slot">
  <article class="tool-card">
    <img src="/images/tools/url-encoder.svg" alt="" class="tool-card__icon">
    <div class="tool-card__body">
      <h3 class="tool-card__title">URL Encoder Decoder</h3>
      <p class="tool-card__desc">Encode or decode URL components instantly, right in your browser.</p>
      <a href="/url-encoder-decoder" class="tool-card__link">Open tool</a>
    </div>
  </article>
</div>

Notice the extra wrapper, .tool-card-slot, around the card itself. That wrapper matters more than it looks like it should — it's the fix for the gotcha below.

Setting up the containment context

.tool-card-slot {
  container-type: inline-size;
  container-name: tool-card;
}

.tool-card {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

container-type: inline-size tells the browser this element establishes a query context based on its own inline (horizontal) size, and container-name gives that context a name we can target explicitly rather than relying on the nearest ancestor container implicitly, which gets confusing fast once you nest cards inside other containered components.

The container query rules

@container tool-card (min-width: 320px) {
  .tool-card {
    flex-direction: row;
    align-items: center;
  }
  .tool-card__icon {
    width: 96px;
    flex-shrink: 0;
  }
}

@container tool-card (max-width: 319px) {
  .tool-card__icon {
    width: 56px;
  }
  .tool-card__desc {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
}

Below 320px of available width, the card stacks vertically and truncates its description to two lines so it doesn't overpower a narrow sidebar. Above that, it switches to a horizontal icon-plus-text layout. The exact same component, same markup, same class names, rendering two genuinely different layouts depending purely on the space its parent gives it — not the viewport, and not any JavaScript resize observer wired up by hand to fake this behavior before container queries existed.

The gotcha that cost me an afternoon

My first attempt put container-type directly on .tool-card and then tried to write @container rules that also targeted .tool-card's own layout. It silently did nothing. The rules just never applied, and there was no console error to point at the problem.

The reason: an element that establishes a containment context cannot be queried against that same context. The container query needs to target a descendant of the container, not the container itself. Once I split the containment (.tool-card-slot) from the element being styled by the query (.tool-card and its children), everything worked immediately. It's one of those things that's obvious once you know it and completely opaque the first time you hit it, because the failure mode is silence rather than an error message.

Naming containers when you have nested cards

Once you have more than one containered component on a page — say, a tool card inside a containered sidebar that's itself inside a containered page layout — unnamed containers get ambiguous, because an unnamed @container query matches the nearest ancestor container of any kind. Naming every container explicitly (container-name: tool-card, container-name: sidebar, and so on) and referencing that name in every query removes the guesswork entirely. It costs a few extra characters and saves you from a query silently attaching to the wrong ancestor once your component tree gets more than one level deep, which is exactly the kind of bug that's hard to spot from reading the CSS alone.

Container query units: sizing relative to the container, not the viewport

Once container-type is set, you also get access to container query length units — cqw, cqh, cqi, cqb — which behave like vw and vh but resolve against the container's dimensions instead of the viewport's. We used this to make the card's title scale smoothly with the card's own width rather than jumping between two fixed sizes at the breakpoint:

.tool-card__title {
  font-size: clamp(0.95rem, 4cqi + 0.5rem, 1.25rem);
}

4cqi means 4% of the container's inline size, so as the card gets wider (more grid columns, a wider sidebar), the title grows proportionally instead of snapping at a single breakpoint the way our old media-query version did. clamp() keeps it from getting comically large in a very wide container or unreadably small in a very narrow one. This is a smaller win than the layout-switching behavior above, but it's the detail that made the component feel like it was actually responding continuously to its space rather than just toggling between two fixed states.

Testing across container size, not just viewport size

The other adjustment this required was to our own testing habits. Resizing the browser window, the instinct everyone has from years of media-query testing, doesn't actually exercise every case for a containered component — a card's container can change width because the grid it's in gained or lost a column, independent of the viewport changing at all. We added a small debug harness page that renders the same card inside several fixed-width wrapper divs side by side (200px, 320px, 480px, 640px) so we can visually check every container breakpoint in one screenshot, regardless of what the browser window itself is doing.

<div style="width: 200px; border: 1px dashed #ccc;">
  <!-- tool-card-slot markup -->
</div>
<div style="width: 320px; border: 1px dashed #ccc;">
  <!-- tool-card-slot markup -->
</div>
<div style="width: 480px; border: 1px dashed #ccc;">
  <!-- tool-card-slot markup -->
</div>

That harness caught a bug that resizing the actual browser window never would have: at exactly 320px of container width, the icon and the two-line description were both trying to claim space at once, overflowing the card by a few pixels — a container-size edge case that has nothing to do with viewport width and would never show up while testing the normal way. We now keep that harness page as a permanent fixture in the codebase rather than a one-off debugging tool, and check it any time we touch the card's styles, the same way a design system might keep a Storybook entry around specifically to catch this kind of regression.

Browser support reality check

Container queries for size (inline-size and size) have solid support across current versions of Chrome, Edge, Firefox, and Safari at this point, which is why we shipped this without a fallback layer. If you need to support older browser versions still showing up in your analytics, the safe pattern is to write your default (non-containered) styles as the reasonable single-column layout first, then layer container query enhancements on top — that way a browser that ignores @container entirely still gets a usable, just less optimal, layout instead of a broken one.

When container queries aren't the right tool

  • If a component only ever appears in one layout context, a media query is simpler and there's no reason to add a containment context for it.
  • Container queries add layout containment, which can affect how percentage-based children and some sizing units behave inside the container — test children carefully rather than assuming it's a drop-in replacement for a plain div.
  • Style queries (querying a custom property rather than size) are still newer and less consistently supported — we didn't reach for them on this project.
  • Deeply nested containered components can get genuinely hard to reason about if every level names its own container inconsistently — agree on a small, documented set of container names for a design system rather than letting every component author invent their own on the spot.

What this actually bought us

The concrete win wasn't visual polish — it was deleting two nearly-duplicate card components that existed purely because the single card couldn't adapt to both contexts, along with the extra maintenance burden of keeping two near-identical sets of styles in sync every time either one needed a tweak, and the small but real risk of the two versions quietly drifting apart the next time someone updated only one of them. One component, one set of styles, two real layouts, driven by actual available space. That's also worth keeping in mind if you're separately working through layout shift issues in your Core Web Vitals — components that silently swap layout based on container size are far less prone to introducing CLS than the old pattern of loading a second, differently-sized card variant via JavaScript after the page has already rendered and settled.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Web Development Free Resources Explore Tools