Skip to content
Chapter 21Lesson 7

Container queries for component layout

How Tailwind container queries 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 md: couldn’t fix it: both slots share one viewport, and a media query only reports the size of the screen. The card’s problem was never the screen; it was the card’s own width, which the viewport can’t tell you. Container queries close that gap. You mark a wrapper as measurable with @container, and the children inside query 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, with a clear rule for when this beats a media query. Start by dragging a container narrower and watching the card decide its own layout.

How a component queries its container’s width

Section titled “How a component queries its container’s width”

The target is a product card that stacks its image above its text when its slot is narrow and sits image-left, text-right when its slot is wide. It decides that itself: no variant="compact" prop from the parent, no useState, no measuring in JavaScript. The component owns its own breakpoint, so the same card stacks in a three-column grid or a sidebar and goes horizontal in a full-width feed, and the parent never told it which.

The slider below drives a real container’s width, and the card reacts through an actual @container rule, not a simulation. Drag it from narrow to wide and watch two things 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.

The pattern has two pieces. First, an ancestor opts in to being measurable with the container-type property. Measuring a box costs the browser extra layout work, so you opt in per-subtree instead of paying that cost on every element. The value you almost always want is container-type: inline-size: it makes the container queryable on its inline-size (width) axis only, leaving its height content-driven and free to grow with whatever’s inside. In Tailwind, @container compiles to container-type: inline-size, so that one utility is the opt-in.

Second, a descendant queries that ancestor. The @-prefixed variants, like @md:flex-row and @lg:grid-cols-2, each compile to a @container (width >= <size>) block that measures the nearest container ancestor, not the viewport. That @ is the entire difference from the viewport variants you already know: you read md: as “at a screen width,” so @md: reads as “at a container width,” with nothing new to learn.

One rule catches most people at first: the container is always an ancestor, and a query never reads the element it’s written on. Put @container and @md: on the same div and nothing happens, because that div would be asking about its own width. The opt-in goes on a wrapper up the tree; the query goes on a descendant inside it.

Here is the pattern in Tailwind, walked through one utility at a time.

<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 only 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 utilities are unprefixed, so they always apply, and 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.

<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 rest. 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 their container units are Baseline widely available since 2025, so in 2026 they’re universal with no polyfill.

Two other container-type values exist, named here only so you recognize them. container-type: size queries both axes but requires a defined height, which lets the box collapse to zero when its content can no longer size it, so reach for it almost never. container-type: normal removes containment. Stay with inline-size.

The container breakpoint scale is smaller than the viewport scale

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

@md: is a breakpoint, but it does not fire at the 768px your md: viewport breakpoint uses. Picture a sidebar widget at 400px wide: roomy for its slot, tiny for a screen. If containers reused the viewport numbers, @md would only ever fire inside a full-width region. So Tailwind ships a separate, smaller scale: viewport md is 768px, while container @md is 448px (28rem).

The scale runs from @3xs to @7xl; the diagram below is for orientation, not memorization. The habit from the last lesson carries over: stay on the scale and find the breakpoint by resizing until the layout breaks, now for the container instead of 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.

For a genuine one-off threshold, the arbitrary form is @min-[475px]: (and @max-[960px]: for the other direction), an escape hatch rather than a first reach. When a project keeps needing another step, grow the scale instead by adding to the --container-* namespace in your theme:

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

That mints an @8xl variant you can use like any built-in step. The 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. Aim only to recognize these when you see them.

Fluid component typography with cqi and clamp()

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

Breakpoints are only half of what containers give you. The other half is continuous sizing: a value that scales smoothly with the box instead of snapping at thresholds. The mechanism is container query units, and they map 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. There’s a unit for each axis (cqb, cqw, cqh, cqmin, cqmax), but you’ll reach for cqi almost every time, for the same reason inline-size is the default container-type: width drives most component sizing.

Here’s where it pays off. A card title that reads at ~16px in a small card and ~24px in a large one needs no breakpoints, just one declaration:

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

clamp() takes a floor, a preferred value, and a ceiling. The floor is 1rem (16px), the ceiling is 1.5rem (24px), and the preferred value is 6cqi, 6% of the container’s width. As the card grows, 6cqi grows with it, and the title scales smoothly between its bounds, continuously, with no @md:text-2xl step and no jump at a breakpoint. This is the title you watched scale on the slider in the lab.

This looks like it breaks a rule you’ve been taught, so it’s worth calling out. Tailwind has no cqi utilities, so you write the fluid value as an arbitrary value: text-[clamp(1rem,6cqi,1.5rem)] for the title, p-[3cqi] for padding that scales with the card. That is not the bracket smell earlier lessons warned about, reaching for text-[17px] when a scale step would do. No scale step can express “6% of the container’s width, clamped between two bounds.” The value is a genuine fluid computation, so the escape hatch is the right tool. 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 target the right one

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

Everything so far assumed one container. With 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 can’t react to the outer panel because the query grabs the closer inner card.

The fix is to name the container and query it by name. In CSS, container-name joins container-type, usually via the container shorthand: container: panel / inline-size. A descendant then addresses it explicitly: @container panel (width >= 28rem) { … }. In Tailwind the name rides along as a / suffix on both ends: @container/panel on the ancestor, @md/panel:flex-row on the descendant. Naming still implies inline-size.

<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. The layout should 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.

Reach for a name sparingly: most components have exactly one container and never need one. Pull it out when the structure nests and an inner container would otherwise shadow the outer one for unnamed queries.

Choosing between viewport and container queries

Section titled “Choosing between viewport and container queries”

You have both tools now; the judgment is knowing which one a problem wants. The rule from the last lesson, fully spelled out:

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

Most real SaaS UIs use both at once. The page shell is viewport-driven: navigation switches from a hamburger to a full bar, the layout splits from one column to two, because those decisions genuinely depend on the screen. The components 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, ask one question: does this element’s layout depend on the screen, or on the space it sits 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 collapses changes because the grid changed, not the screen, so it’s container too.

One nearby tool is easy to confuse with container queries, since 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 the same inside. A container query does the opposite: @container plus @md:flex-row makes the items respond to the container, so each card lays itself out from the width it ended up with. They don’t compete, they compose. auto-fit decides how many cards fit per row; each card’s own @container decides its internal layout from the cell width that results. Pairing them gives a card grid that’s fluid all the way down, 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 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 is to apply the rule, not recall it, which is what you’ll do 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. The starter card stacks in both slots; add the container setup so the card in the wide slot flips to side-by-side 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

When the wide card goes horizontal and the narrow one stays stacked, you have a 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: type, color, decoration, state, motion, and adaptation to both the viewport and the box an element lives in. Carry forward the habit this lesson is built on: author components that lay themselves out from the space they’re given rather than waiting for a parent to hand down the right variant. The next chapter composes these adaptive pieces into larger ones.