Building Responsive Cards with Container Queries: A Production-Ready Guide

Problem statement

A card is the most re-used component in most design systems, and it is re-used at wildly different widths. The same article card appears three-across in a feed at roughly 300px, alone in a 700px featured slot, and squeezed into a 260px rail on the same page at the same time. Only one of those wants a stacked layout with the thumbnail on top; the widest one wants the thumbnail beside the text. A viewport breakpoint cannot tell these three apart, because at any given moment all three have the same viewport. This page builds one card that decides for itself, using a @container rule keyed to the width the card actually received. It sits under responsive component patterns within Mastering Container Queries & Responsive Layouts.

Approach rationale

The alternative people reach for first is a set of modifier classes — card--compact, card--wide — chosen by whoever places the card. That works right up until the placement is dynamic, and then it becomes a coordination problem: the template author has to know how wide the slot will be, which they usually do not, and any layout change means auditing every call site. A ResizeObserver fixes the correctness problem and introduces two new ones: the first paint happens before the observer has measured anything, so the card renders in the wrong shape and then snaps, and every observed card adds a callback that runs during layout. @container moves the decision into the same layout pass that determined the width in the first place, so there is no wrong first frame to correct.

The one real cost is structural. Containment must be declared on an ancestor of the thing being styled, so the card needs a wrapper element it did not previously have. Self-querying is ruled out for a concrete reason: sizing a box from its own contents and then restyling those contents based on the result is circular, and the spec closes the loop by requiring the container to be settled independently first. Put the wrapper inside the component's own template rather than asking every consumer to remember it, and the cost stays invisible at the call site.

Two things this page deliberately does not do. It does not use container-type: size, which requires the block axis to be constrained too and will collapse a card whose height comes from its content. And it does not treat the card's internal reflow as a substitute for the page's own grid — the grid decides how wide the card gets, the card decides what to do with that width. Where the component is a sidebar-and-main pair rather than a single card, the two-threshold treatment in container query sidebar layouts is a closer fit.

Picking the threshold

The single number in the query prelude should come from arithmetic on the content, not from a phone's screen size. Decide the smallest width at which the thumbnail still communicates anything — say 120px. Decide the smallest comfortable measure for the text beside it — around 30 characters, roughly 15rem at body size. Add the gap. That sum, about 400px, is the width below which side-by-side is worse than stacked, so it is the threshold. Written down that way the number has a justification you can re-derive when the design changes, instead of being a constant nobody dares touch.

Card layout switch at the container threshold Below 400px the media sits above the text; at or above 400px the media moves beside the text. @container card (min-width: 400px) narrow: stacked media wide: side by side media

Complete working implementation

One file, no build step. The page places three cards in slots of deliberately different widths so the same component resolves to different shapes side by side.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Container-query card</title>
<style>
  :root { color-scheme: light dark; }
  * { box-sizing: border-box; }
  body { margin: 0; padding: 1.5rem; font: 16px/1.5 system-ui, sans-serif; }

  /* Page layout only. Its job is to hand each card a width;
     it knows nothing about what the card does with that width. */
  .layout {
    display: grid;
    gap: 1.5rem;
    grid-template-columns: 1fr;
    max-width: 68rem;
    margin-inline: auto;
  }
  @media (min-width: 60rem) {
    .layout { grid-template-columns: 2fr 1fr; }
    .layout > :first-child { grid-column: 1 / -1; }
  }

  /* THE WRAPPER. Containment lives here, never on .card itself,
     because an element cannot query its own size. */
  .card-shell {
    container-type: inline-size;
    container-name: card;
  }

  /* Base state = the narrow state. Everything below the threshold
     gets this with no query evaluated at all. */
  .card {
    display: grid;
    gap: 0.75rem;
    padding: 1rem;
    border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
    border-radius: 12px;
  }

  .card__media {
    width: 100%;
    aspect-ratio: 16 / 9;   /* Reserves space before the image decodes. */
    object-fit: cover;      /* Crop rather than distort at any ratio. */
    border-radius: 8px;
    background: #7aa2ff33;
  }

  .card__title {
    margin: 0;
    /* Scales with the card, not the window: 1cqi is 1% of the
       container's inline size, so a wide slot gets a larger title. */
    font-size: clamp(1.05rem, 0.95rem + 1.2cqi, 1.5rem);
    line-height: 1.25;
  }

  .card__text { margin: 0; }

  .card__meta {
    margin: 0;
    font-size: 0.875rem;
    opacity: 0.75;
  }

  /* WIDE STATE. 400px is the derived threshold: 120px of usable
     thumbnail + ~15rem of readable measure + the gap. */
  @container card (min-width: 400px) {
    .card {
      grid-template-columns: 34cqi 1fr;  /* media track tracks the card */
      align-items: start;
      column-gap: 1.25rem;
      padding: 1.25rem;
    }

    /* The media spans all three text rows so the two columns end level. */
    .card__media {
      grid-row: 1 / -1;
      aspect-ratio: 1;
    }
  }
</style>
</head>
<body>
  <div class="layout">

    <!-- Wide slot: the card resolves to side-by-side. -->
    <div class="card-shell">
      <article class="card">
        <div class="card__media" role="img" aria-label="Article thumbnail"></div>
        <h2 class="card__title">Track sizing in practice</h2>
        <p class="card__text">This card sits in a full-width slot, so its
          container query matches and the media moves beside the text.</p>
        <p class="card__meta">8 min read</p>
      </article>
    </div>

    <!-- Medium slot: still above the threshold. -->
    <div class="card-shell">
      <article class="card">
        <div class="card__media" role="img" aria-label="Article thumbnail"></div>
        <h2 class="card__title">Containment costs</h2>
        <p class="card__text">Same markup, same stylesheet, different width.</p>
        <p class="card__meta">5 min read</p>
      </article>
    </div>

    <!-- Narrow rail: below the threshold, so the base stacked state wins. -->
    <div class="card-shell">
      <article class="card">
        <div class="card__media" role="img" aria-label="Article thumbnail"></div>
        <h2 class="card__title">In the rail</h2>
        <p class="card__text">Below 400px this one stays stacked.</p>
        <p class="card__meta">3 min read</p>
      </article>
    </div>

  </div>
</body>
</html>

Note that the wide state is written as an override and the narrow state as the base. That ordering is not cosmetic: a browser with no container query support evaluates the base rules and nothing else, so it lands on the stacked card, which is the layout that is never wrong. Enhancement adds the second column; it never has to remove a broken one.


Performance & Containment Optimization

This is the finished card. Because the query measures the container rather than the viewport, resizing the frame alone is enough to switch the layout.

Live demoCard that reflows on its own width
Drag the frame narrower: the card switches from a row to a stack on its own size, not the window size. Drag the bottom-right corner to resize the frame in either direction.

The performance profile is worth understanding rather than cargo-culting. container-type: inline-size implies contain: layout inline-size on the wrapper, which already tells the engine that nothing inside the card can influence the layout of anything outside it. That boundary is where the win comes from: a card whose text rewraps cannot dirty its siblings. Adding contain: paint on top is worthwhile when cards carry shadows or overflow-clipped media, because it lets the engine skip painting descendants that fall outside the box. Adding contain: style is rarely useful here and can surprise you by scoping counters. Measure before layering more containment on; the default that container-type gives you is usually the whole benefit.

The thing genuinely worth avoiding is a query whose result feeds back into the queried size. If a rule inside @container card (min-width: 400px) changes something that alters the wrapper's inline size, the engine has to resolve a circularity, and browsers break the loop by ignoring your rule rather than by looping forever. Keep the wrapper's width determined entirely by the page grid, and keep the query's effects strictly inside the card.


Key technique callout: the wrapper is the query subject

The mechanism the whole pattern rests on is that @container matches against the nearest ancestor that established a containment context, not against the element being styled. .card-shell declares container-type: inline-size, which makes it a query container and simultaneously applies inline-size containment to it — the browser now sizes that box from the outside in, ignoring its contents' intrinsic width. Because its width is settled before its descendants are laid out, the engine can answer min-width: 400px and then lay out .card with the answer already known. That is the entire reason there is no first-frame flash and no second layout pass.

It is also why the wrapper must not be the card. Put container-type on .card and the rules inside the query still apply to .card's descendants, but any rule targeting .card itself silently never matches, which reads like a browser bug and is actually the spec working. The naming half matters too: container-name: card means a nested component can declare its own containment without accidentally intercepting this query, because @container card (…) skips any unnamed or differently-named container on the way up the tree.


Variation: a horizontal-only card that never stacks

Some cards should never stack — a compact "now playing" or notification row reads better as a single line at every width, with the media shrinking instead of relocating. The same containment gives you that with a different response: hold the two columns and let the query manage the media track and the text density instead of the axis.

/* Always two columns. The query tunes proportions, not direction. */
.card--row { grid-template-columns: 3rem 1fr; align-items: center; }

@container card (min-width: 380px) {
  .card--row { grid-template-columns: 4.5rem 1fr; column-gap: 1rem; }
}

/* Below the threshold there is no room for the summary line;
   remove it from the layout rather than truncating it to nothing. */
@container card (max-width: 379.98px) {
  .card--row .card__text { display: none; }
}

Two cautions on that last block. Use display: none only for content that is genuinely redundant at that size, because it leaves the accessibility tree as well as the screen — if the text carries information available nowhere else, clamp it with -webkit-line-clamp instead. And note the 379.98px upper bound: min-width: 380px and max-width: 380px both match at exactly 380px, so an unadjusted pair applies both blocks on that one width. Modern engines also accept the range syntax, @container card (width < 380px), which expresses the exclusive bound directly and is the clearer choice in new code.


Browser support note

Size container queries are Baseline and have been since 2023: Chrome and Edge 105, Safari 16.0, and Firefox 110 all ship container-type, container-name, and @container, as well as the cqi/cqw units used for the title and the media track. The range syntax (width < 380px) landed in the same releases. color-mix() in the border needs Chrome 111, Safari 16.2, and Firefox 113, so substitute a plain rgb() border if you support anything older.

For pre-2023 engines, gate the enhancement on a feature query and approximate it with a viewport breakpoint chosen to match the width the card usually gets — accepting that it will be wrong in the rail, which is the limitation container queries exist to remove:

@supports not (container-type: inline-size) {
  @media (min-width: 60rem) {
    .card {
      grid-template-columns: 9rem 1fr;
      column-gap: 1.25rem;
    }
    .card__media { grid-row: 1 / -1; aspect-ratio: 1; }
  }
}

Because the wide state is the enhancement and the stacked state is the base, no @supports (container-type: inline-size) wrapper is needed around the modern rules — an engine that does not understand @container simply discards the block.


Common Issues & Debugging Steps

IssueRoot CauseDirect Fix
Query never matches at any widthcontainer-type is on the element the rule targets, or on a descendant of it, rather than on an ancestor.Move container-type: inline-size to a wrapper that has the styled element as a descendant. Confirm in DevTools that the wrapper carries a container badge.
Card is the wrong shape for one frame on loadAn image without reserved space forces a reflow after decode, changing the wrapper's height and re-running dependent layout.Set aspect-ratio and width on media so space is reserved before the bytes arrive. Inline-size containment already prevents the height change from escaping the card.
Both the min-width and max-width blocks applyThe two thresholds share an inclusive boundary value, so both match at exactly that width.Use the range syntax (width < 380px / width >= 380px), or offset one bound by a fraction of a pixel.
Nested card picks up the outer queryThe inner component declared containment without a name, so an unnamed @container matched the nearest container instead of the intended one.Give every containment context a container-name and always name it in the query prelude.

FAQ

How do I pick the container width threshold for a card? Derive it from the card's contents rather than from a device size. Add the smallest usable media width to the smallest comfortable text measure plus the gap, then set the threshold just above that sum. A number reached that way can be re-derived when the design changes, instead of surviving as a constant nobody dares touch.

Why does my card query never match even though the parent has container-type? An element cannot query itself, and a container query only matches containers that are ancestors. If container-type sits on the card itself, move it to a wrapper element that has the card as a descendant. Rules targeting descendants will still work in that broken arrangement, which is why the bug is easy to misread.

Can I nest container queries for complex card grids? Yes. Each container establishes an independent query context. Give parent and child wrappers distinct container-name values so a nested @container rule resolves against the ancestor you intended rather than the nearest one. Unnamed queries always bind to the closest container, which is rarely what a nested component wants.

Do container queries replace media queries entirely? No. Media queries remain the right tool for page-level concerns such as print styles, orientation, and the overall page skeleton. Container queries handle component-level reflow inside whatever region the component lands in. Most production stylesheets end up with a handful of the former and many of the latter.


Related articles

More pages in the same section.