Skip to content
Chapter 21Lesson 7

Container queries for component-level layout

How container queries in Tailwind let a component adapt its layout to the width of the slot it lands in rather than the size of the screen.

In the last lesson you left a card unfinished. The same <ProductCard> looked right in a wide feed and cramped in a narrow sidebar, and you couldn’t fix it with md:, because both slots share one viewport and a media query only ever reports the size of the screen. The card’s problem was never the screen; it was the card’s own width, and the viewport has no way to tell you that. Container queries close that gap. You mark a wrapper as measurable with @container, and the children inside ask that wrapper’s width with @md:flex-row instead of the window’s. By the end of this lesson you’ll have one card, authored once, that lays itself out correctly in the dashboard grid, the sidebar, and the full-width feed without the parent doing anything, plus a clear rule for when this beats a media query. Start by dragging a container narrower and watching the card decide its own layout.

A component that adapts to its slot, not the screen

Section titled “A component that adapts to its slot, not the screen”

Here’s the target: a product card that stacks its image on top of its text when its slot is narrow, and sits image-left, text-right when its slot is wide. It figures that out itself, without the parent passing down a variant="compact" prop, without a useState, without measuring anything in JavaScript. The component owns its own breakpoint. Drop it in a three-column dashboard grid and each cell is narrow, so it stacks. Drop it in a sidebar and it stacks. Drop it in a full-width feed row and it goes horizontal. One component, three correct layouts, and the parent never told it which.

The shape that makes this work has two pieces. First, the parent opts in to being measurable:

<div className="@container"></div>

That @container is the wrapper saying “I am a box whose width can be queried.” Second, a child inside it queries that width:

<div className="flex flex-col @md:flex-row"></div>

That reads “stack by default; go side-by-side once my container passes the @md width.”

The difference between @md: and the md: you learned in the last lesson is worth pausing on, because the whole idea rests on one character. In md:flex-row, the md is the screen: it fires at a viewport width. In @md:flex-row, the @md is this container: it fires at the wrapper’s width. The @ prefix is the only difference. The grammar is the same; what changes is what gets measured, the window or the box the component happens to land in.

Before we name any of the underlying CSS, it helps to feel the effect first. The slider below drives a real container’s width, and the card inside reacts through genuine container-query CSS, an actual @container rule rather than a simulation. Drag it from narrow to wide and watch two things happen at once: the layout flips from stacked to side-by-side, and the title grows smoothly with the box.

280px

Wireless Headphones

$129

Container width 280px Layout vertical Title size 17px
Drag the container — not the window. The card flips layout at its own @md width (448px) and the title scales with cqi.

That loop is the whole lesson in miniature: you changed the box’s width and the component laid itself out again. The wrapper had a property that said “measure me,” and the card inside read that measurement and reacted. Everything from here puts precise names on those two moves. Start with what the wrapper is actually measuring, which is its inline-size .

The model: a parent opts in, descendants ask

Section titled “The model: a parent opts in, descendants ask”

Now that you’ve felt the effect, here is the precise model behind it. A container query can’t just point at any element and ask its width. An ancestor has to first declare itself queryable, and it does that with the container-type property. Measuring a box costs the browser extra layout work, so you opt in per-subtree rather than paying that cost on every element on the page. That’s why the parent declaration exists at all, and it’s also why the most common beginner mistake is forgetting it, which we’ll come back to shortly.

The value you reach for almost every time is container-type: inline-size. It makes the container queryable on its inline (width) axis only, and leaves its block size, its height, content-driven and free to grow with whatever’s inside. That asymmetry is what you want in practice: you almost always care about how wide your slot is, while letting the box get as tall as its content needs. Because querying width and letting height flow is so much the common case, it’s the default. In Tailwind, @container compiles to container-type: inline-size, so that single utility is the opt-in.

Once an ancestor is a container, any descendant can query it. A rule written as @container (width >= 28rem) { … } measures that ancestor, not the viewport. In Tailwind that’s the family of @-prefixed variants, like @md:flex-row and @lg:grid-cols-2, each compiling to a @container (width >= <size>) block. The @ is what separates a container variant from a viewport variant. This is where the variant grammar from the last lesson pays off: you already read md: as “at a screen width,” so @md: reads as “at a container width” without anything new to learn.

There’s one rule that catches most people at first: the container is always an ancestor, and a query never reads the element it’s written on. The @container declaration goes on a wrapper up the tree; the @md: query goes on a descendant inside it. Put both on the same div and nothing happens, because that div would be asking about its own width, and a container query only ever looks upward for the nearest container.

Here’s the two-piece pattern in Tailwind, walked through one part at a time so you can see the CSS that each utility compiles to.

<article className="@container">
<div className="flex flex-col gap-4 @md:flex-row">
<img className="rounded-md @md:w-40" />
<div className="@md:flex-1"></div>
</div>
</article>

The opt-in. @container compiles to container-type: inline-size, so this <article> is now measurable on its width axis. Nothing about it looks different yet; it has simply declared itself queryable.

<article className="@container">
<div className="flex flex-col gap-4 @md:flex-row">
<img className="rounded-md @md:w-40" />
<div className="@md:flex-1"></div>
</div>
</article>

The default, narrow layout: a vertical stack with the image above the text. These are unprefixed utilities, so they always apply; this is what renders until a container query overrides it.

<article className="@container">
<div className="flex flex-col gap-4 @md:flex-row">
<img className="rounded-md @md:w-40" />
<div className="@md:flex-1"></div>
</div>
</article>

The query. @md:flex-row compiles to @container (width >= 28rem) { flex-direction: row }, flipping to side-by-side once the article is at least 28rem wide, regardless of screen size. The @ is what makes it read the container, not the viewport.

<article className="@container">
<div className="flex flex-col gap-4 @md:flex-row">
<img className="rounded-md @md:w-40" />
<div className="@md:flex-1"></div>
</div>
</article>

Per-element overrides inside that same @md query: the image takes a fixed 10rem width while the text column takes the remaining space. Each utility carries its own @container (width >= 28rem) wrapper.

1 / 1

You can reach for this without checking a support table. Size container queries and the container units that go with them are Baseline : cross-browser since early 2023, and Baseline widely available since 2025. In 2026 they’re universal, with no polyfill needed.

There are two other container-type values, named here only so you recognize them. container-type: size queries both axes, but it requires the container to have a defined height. That’s risky: the content inside can no longer size the box that’s measuring it, so it’s easy to collapse the whole thing to zero. Reach for it almost never, and stay with inline-size. The other value, container-type: normal, simply removes containment. Tailwind exposes the size variant as @container-size, but you’ll rarely write it.

The container breakpoint scale is smaller than the viewport scale

Section titled “The container breakpoint scale is smaller than the viewport scale”

When you reach for @md: you’re using a breakpoint, but it is not the same 768px your md: viewport breakpoint fires at. To see why that matters, picture a sidebar widget at 400px wide: that’s roomy for its slot but tiny for a screen. If the container scale reused the viewport numbers, @md would never fire inside anything but a full-width region. So Tailwind ships a separate, smaller scale for containers: viewport md is 768px, while container @md is 448px (28rem). The names match, but one is measuring a screen and the other a box.

The full scale runs from @3xs up to @7xl. You don’t need to memorize it; the diagram below is just for orientation. The habit is the same one from the last lesson, stay on the scale and find the breakpoint by resizing until the layout breaks, now applied to the container rather than the viewport.

viewport scale (reference)
sm: 640
md: 768
lg: 1024
xl: 1280
2xl: 1536
@3xs 256
@2xs 288
@xs 320
@sm 384
@md 448
@lg 512
@xl 576
@2xl 672
@3xl 768
@4xl 896
@5xl 1024
@6xl 1152
@7xl 1280
container scale

Container breakpoints (foreground) are smaller than viewport breakpoints (faded). A container’s @md fires at 448px, while a screen’s md doesn’t fire until 768px.

When a one-off threshold is genuinely the right call, the arbitrary form is @min-[475px]: (and @max-[960px]: for the other direction). Treat it the way you treat any bracket value: it’s an escape hatch, not the first reach. If a project consistently needs another step, grow the scale instead of scattering brackets. You do that by adding to the --container-* namespace in your theme:

@theme {
--container-8xl: 96rem;
}

That mints an @8xl variant you can use everywhere, exactly like the built-in steps. The container scale also has max-* and ranged variants: @max-md: means “below the container’s md,” and @sm:@max-md: targets a single band between two breakpoints. You’ll reach for those rarely, so just aim to recognize them when you see them.

Fluid component typography with cqi and clamp()

Section titled “Fluid component typography with cqi and clamp()”

Discrete breakpoints are only half of what containers give you. The other half is continuous sizing, where a value scales smoothly with the box instead of snapping at thresholds. The mechanism is container query units, and they map cleanly onto the viewport units you already know. Where 1vw is 1% of the viewport’s width, 1cqi is 1% of the container’s inline size. The full set exists: cqb is 1% of block size, cqw and cqh are the explicit width and height forms, and cqmin and cqmax are the smaller and larger of the two. In practice cqi is the one you reach for almost every time, for the same reason inline-size is the default container-type: width drives most component sizing.

Here’s where that pays off. A card title that reads at ~16px in a small card and ~24px in a large one doesn’t need a stack of breakpoints; it’s a single declaration:

font-size: clamp(1rem, 6cqi, 1.5rem);

clamp() takes three arguments: a minimum floor, a preferred value, and a maximum ceiling. Here the floor is 1rem (16px), the ceiling is 1.5rem (24px), and the preferred value is 6cqi, which is 6% of the container’s width. As the card grows, 6cqi grows with it, and the title scales smoothly between its floor and ceiling. There’s no @md:text-2xl step and no sudden jump at a breakpoint; the title grows continuously with the slot. This is exactly what you watched in the lab: the title scaling on the slider was this one clamp(1rem, 6cqi, 1.5rem) rule.

One detail here looks like it breaks a rule you’ve been taught, so it’s worth calling out. Tailwind has no first-class cqi utilities. You write the fluid value as an arbitrary value: text-[clamp(1rem,6cqi,1.5rem)] for the title, or p-[3cqi] for padding that scales with the card. That is not the bracket smell the earlier lessons warned you off. That smell is reaching for text-[17px] when a scale step would do, inventing a one-off where the system already has an answer. Here no scale step can express “6% of the container’s width, clamped between two bounds.” The value is genuinely a fluid computation, so the escape hatch is the right tool, and the only one. Stay on the scale for what the scale covers, and reach for brackets when the value is a real computation like this one.

Naming a container so deep children query the right one

Section titled “Naming a container so deep children query the right one”

Everything so far assumed a single container. Once you have two, a new problem appears. An unnamed @container query always binds to the nearest container ancestor. So when containers nest, say a card inside a panel where both are containers, a deep child that wants to react to the outer panel can’t reach past the inner card to do it. The query grabs the closest container, and sometimes the closest one isn’t the one you mean.

The fix is to give the container a name and query it by name. In CSS that’s container-name alongside container-type, usually written with the container shorthand: container: panel / inline-size. Then a descendant addresses it explicitly: @container panel (width >= 28rem) { … }. In Tailwind, the name rides along as a / suffix on both ends: @container/panel on the ancestor, and @md/panel:flex-row on the descendant. Naming still implies inline-size, so you’re not giving anything up.

<section className="@container"> {/* outer panel */}
<article className="@container"> {/* inner card */}
<div className="flex flex-col @md:flex-row"></div>
</article>
</section>

The @md: binds to the nearest container, the inner card, not the outer panel. You wanted the layout to respond to the panel’s width, but the inner @container is closer, so it wins. There’s no error; the query just measures the wrong box.

A name is the whole reason this works, and it’s also why you reach for one sparingly. Most components have exactly one container and never need a name. Pull naming out specifically when the structure nests and an inner container would otherwise win. The behavior to remember is that an inner unnamed container shadows an outer one for unnamed queries, and a name is how you reach past it.

Choosing between viewport and container queries

Section titled “Choosing between viewport and container queries”

You now have both tools, which brings you to the part of responsive design that takes real judgment: knowing which one a given problem wants. The rule is the one the last lesson introduced, now fully spelled out:

Page-level structure → viewport query (md:). Component-level adaptation → container query (@md:).

The part that surprises people is that most real SaaS UIs use both at once. The page shell is viewport-driven: the navigation switches from a hamburger to a full bar, and the layout splits from one column to two, because those decisions genuinely depend on the screen. The components living inside that shell are container-driven: a card adapts to its grid cell, a sidebar widget collapses with its parent, because those depend on the slot, not the window.

When you’re unsure which a given element wants, ask one question: does this element’s right layout depend on the screen, or on the space it happens to be in? Screen means md:; slot means @md:. The top-level app navigation depends on the screen, so it’s viewport. A <ProductCard> reused across the feed, the sidebar, and the grid depends on its slot, so it’s container. A stat tile that’s four-across on a wide dashboard and one-across when the grid drops to a single column is changing because the grid changed, not because the screen did, so it’s container too.

There’s one more tool nearby that you already know, and it’s easy to confuse with container queries because both are “responsive without breakpoints.” From the grid lesson, grid-cols-[repeat(auto-fit,minmax(280px,1fr))] makes the container respond to its items: the track count flexes to fit however many cards there are, but each card stays internally the same. A container query does the opposite. @container plus @md:flex-row makes the items respond to the container, so each card lays itself out based on the width it ended up with. They aren’t competitors; they compose. auto-fit decides how many cards fit per row, and each card’s own @container decides its internal layout from the cell width that came out of that. Pairing the two gives you a card grid that’s fluid all the way down, which is the standard approach in 2026.

The same rule tells you when not to reach for a container query. The page shell rarely needs one; making everything a container adds layout cost and a layer of indirection for no gain. Use viewport queries where the viewport is the honest answer.

Sort these real scenarios into the tool each one calls for. The goal isn’t to recall the rule but to apply it, which is what you’ll be doing every time you build a screen.

Decide which query each layout change wants: does it depend on the screen, or on the space the element sits in? Drag each item into the bucket it belongs to, then press Check.

Viewport query (md:) Depends on the screen size
Container query (@md:) Depends on the slot's size
The top app navigation bar switching from a hamburger to a full bar
The page splitting from one column to two on desktop
A marketing hero’s heading shrinking on phones
A ProductCard used in both the sidebar and the feed
A stat tile whose inner layout changes when the dashboard grid drops to one column
A comment card that puts the avatar on the left when wide and on top when narrow

Now write the pattern yourself. The exercise below renders the same <ProductCard> twice, once in a narrow wrapper and once in a wide one, so a single correct card definition produces a stacked layout in the narrow slot and a side-by-side layout in the wide slot at the same time. That’s the whole lesson made literal: one component, two layouts, neither wrapper telling the card which to use. Right now the starter card stacks in both slots, which is wrong; the wide slot should go horizontal. Add the container setup and watch the card in the wide slot flip while the narrow one stays stacked.

Make the card lay itself out from its slot. Add `@container` to the card root, then `@md:flex-row` to the inner layout (with `@md:w-40` on the image and `@md:flex-1` on the body). The same component should render stacked in the narrow slot and side-by-side in the wide slot — without either wrapper telling it which.

Target
Your output LIVE

If the card in the wide slot went horizontal while the one in the narrow slot stayed stacked, you’ve built a genuinely portable component. It carries its own layout logic and reads it from whatever space it’s given, with no prop, no state, and no JavaScript measuring the DOM.

That completes the visual surface. Across this chapter you’ve put in place everything that sits on top of layout: how text reads, how color renders, how an element decorates itself and responds to interaction, how it moves, and now how it adapts, both to the viewport and to the box it lives in. The habit to carry forward is the one this lesson is built around: author components that lay themselves out from the space they’re given, rather than depending on a parent to hand down the right variant. That’s what makes a component genuinely reusable. It’s also the foundation the next chapter builds on, where you start composing these adaptive pieces into larger ones.