All posts
7 min read

Container Queries Make Section Components Honest

Viewport breakpoints answer the wrong question: they tell a component how wide the screen is, not how much room it actually has. Here's the full container queries workflow in Tailwind v4, the containment gotchas nobody mentions, and a rule for when md: is still correct.

tailwindcsscontainer-queries

Every md:grid-cols-3 in a reusable component is a small lie waiting for the right layout to expose it.

The lie goes like this: md: means "when the viewport is at least 768px wide". But what the component actually wants to know is "do I have at least ~700px to work with". Those are the same question exactly as long as the component spans the full page. The moment someone drops it into a sidebar, a modal, a split view, or a dashboard panel, they diverge: the viewport is 1400px, md: fires, and your three-column grid crams itself into a 320px rail, one word per line.

For years we accepted this because CSS gave us nothing better. That excuse expired: container queries have shipped in every major browser since early 2023, and Tailwind v4 supports them in core, no plugin. This post is the complete workflow, including the sharp edges.

The two-line mechanic

Container queries need two participants: an ancestor that declares itself measurable, and descendants that ask about it.

css
/* 1. The ancestor opts in */
.card-slot {
  container-type: inline-size;
}

/* 2. Descendants query the nearest container, not the viewport */
@container (min-width: 28rem) {
  .stats { grid-template-columns: repeat(3, 1fr); }
}

In Tailwind v4 that's the @container utility on the parent and @-prefixed variants on the children:

tsx
<div className="@container">
  <dl className="grid grid-cols-1 gap-4 @md:grid-cols-3">
    <Stat label="Deploys" value="1,204" />
    <Stat label="Uptime" value="99.98%" />
    <Stat label="p95" value="212ms" />
  </dl>
</div>

@md: here means "when the container is at least 28rem", regardless of what the viewport is doing. Same component, honest in a sidebar, honest full-width.

The container scale mirrors the sizing scale you already know:

VariantMin widthVariantMin width
@3xs16rem (256px)@2xl42rem (672px)
@2xs18rem (288px)@3xl48rem (768px)
@xs20rem (320px)@4xl56rem (896px)
@sm24rem (384px)@5xl64rem (1024px)
@md28rem (448px)@6xl72rem (1152px)
@lg32rem (512px)@7xl80rem (1280px)
@xl36rem (576px)@min-[...]arbitrary

Two things worth internalizing. First, @md (28rem container) is a much smaller threshold than md: (48rem viewport); when refactoring, re-derive your breakpoints from the component's actual needs instead of transliterating. Second, @max-md: and range stacking (@md:@max-xl:flex-row) exist, so the full min/max vocabulary carries over.

Refactoring a real section

Here's the shape of the change on a feature-card grid. Before, viewport guesses; after, container truth:

diff
- export function FeatureGrid({ features }: FeatureGridProps) {
-   return (
-     <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
+ export function FeatureGrid({ features }: FeatureGridProps) {
+   return (
+     <div className="@container">
+       <div className="grid grid-cols-1 gap-6 @xl:grid-cols-2 @4xl:grid-cols-3">
        {features.map((f) => (
          <FeatureCard key={f.title} {...f} />
        ))}
+       </div>
      </div>
    );
  }

Notice the extra wrapper. It isn't stylistic. A container query can only be answered by an ancestor: an element can't query its own size, because its size might depend on the very styles the query would change (a circular dependency the spec forbids). The @container div measures; the grid inside asks. If you take one habit from this post, it's this pairing.

The cards themselves can go further. A card that's sometimes in a 3-up grid and sometimes alone in a sidebar can restructure itself:

tsx
export function FeatureCard({ icon: Icon, title, body }: FeatureCardProps) {
  return (
    <div className="@container rounded-xl border bg-card p-6">
      {/* Stacked when narrow, icon-beside-text when the card itself is wide */}
      <div className="flex flex-col gap-4 @sm:flex-row @sm:items-start">
        <Icon className="size-6 shrink-0 text-primary" />
        <div>
          <h3 className="font-semibold">{title}</h3>
          <p className="mt-1 text-sm text-muted-foreground">{body}</p>
        </div>
      </div>
    </div>
  );
}

Containers nest freely. The grid queried the section's slot; the card queries the grid cell it landed in. Each layer answers for itself, which is precisely what "reusable" was always supposed to mean.

When two containers are in play and you need to skip past the nearest one, name them:

tsx
<aside className="@container/sidebar">
  <div className="@container/widget">
    {/* Queries the widget by default; the sidebar when asked by name */}
    <p className="@lg/sidebar:text-base text-sm">...</p>
  </div>
</aside>

The gotchas, because there are real ones

Container queries are not free. container-type: inline-size applies containment, and containment has behavioral consequences that will surprise you at 6pm on a Friday.

Shrink-wrapped containers collapse. Inline-size containment means the element's width may not depend on its contents (the browser sizes it as if it were empty, to break the circular dependency). For block-level elements that fill their parent, nothing changes. But put @container on something sized by its content (an inline-block, a w-fit chip, a flex item with content-based basis) and it collapses toward zero width. Rule of thumb: containers should get their width from their parent, never from their children.

Fixed and absolute children get re-parented. Containment establishes the element as the containing block for positioned descendants. A position: fixed dropdown inside a container no longer positions against the viewport; it positions against the container. If a menu, tooltip, or dialog inside your component suddenly renders in the wrong place after you added @container, this is why. Portal-based components (anything rendering through Radix's Portal) are immune, because their DOM lives outside the container entirely. Hand-rolled fixed positioning is not.

Audit before you add @container

Before converting a section, grep it for fixed and absolute. Positioned elements that escape upward (dropdown panels, floating badges pinned to the viewport) need either a portal or a rethink. This is the single most common breakage in real conversions, and it fails silently: no error, just a menu opening inside a card.

Container units are the bonus prize. Once an ancestor is a container, descendants can use cqw, cqi, cqmin units: percentages of the container's size. This unlocks genuinely fluid internals, like type that scales with the card instead of stepping at breakpoints:

tsx
{/* Scales smoothly from 1rem toward 1.5rem as the container grows */}
<h3 className="text-[clamp(1rem,4cqi,1.5rem)] font-semibold">{title}</h3>

Used sparingly (display numbers, stat values, decorative numerals), this removes whole tiers of breakpoint fiddling.

The iframe nuance, or why previews lie less than you think

Here's a subtlety worth knowing if you evaluate component libraries. The live previews on this site (including the embeds in these posts) render each block inside an iframe. An iframe gets its own viewport: md: inside the frame responds to the frame's width, not your monitor's. So in an iframe, viewport breakpoints accidentally behave exactly like container queries, and resizing the frame exercises a block's full responsive range honestly:

Live · Service Grid With Icons
View block
Inside this frame, the frame is the viewport. Viewport breakpoints tell the truth here; they'd lie in a sidebar div on your page.

The trap is the asymmetry: the preview was honest, but your sidebar <div> is not an iframe. The block that stacked beautifully at every frame width will still consult your full-screen viewport the moment you embed it in a narrow slot. The preview didn't lie; the units did. Container queries close that gap for real.

When viewport breakpoints are still correct

Container queries are not a migration; they're a scoping decision. The honest rule:

One sentence version: breakpoints for the page, containers for the pieces.

That split is also how you retrofit incrementally. You don't rewrite sections; you add @container to the slots in your layout that have non-obvious widths (sidebars, panels, preview panes), then convert the components you actually place there, leaf-first. Each conversion is local, testable by dragging one divider, and independent of the rest of the page.

Blocks you install from a registry are source in your repo, so this refactor is available to you on day one: open the file, add the wrapper, swap the prefixes. Ten minutes per component, and it never lies about its width again.