Confession: I spent two years telling myself container queries were a “nice to have” and kept shipping media-query hacks instead. Then last month a sidebar-width card component finally forced my hand, and within an afternoon I was quietly rewriting four older components to use them. If you’ve also been squinting at the docs and thinking “yeah I’ll get to it,” this post is for you.
I’m going to walk through the exact before/after code, when I reach for @container vs a media query, and the one footgun that tripped me up for a solid hour. No theory lecture. Just the stuff I actually needed.
What container queries actually solve
The problem: a card component has no idea how wide its parent is. You might drop the same card into a full-width hero slot on one page and a narrow sidebar on another. Media queries only see the viewport, so the card’s layout responds to the wrong thing.
Media queries say “when the screen is 768px wide.” Container queries say “when this container is 400px wide.” That’s the whole idea. The browser-level feature landed in stable Chrome, Safari, and Firefox back in 2023, and coverage is now sitting above 93% globally if you check caniuse for container queries. I stopped worrying about the fallback question somewhere last year.
Tailwind shipped first-class support for them in v4 as a core plugin. In v3 you needed @tailwindcss/container-queries installed separately; in v4 it’s baked in, so you get the @container directive and the @sm:, @md:, @lg: variants with no extra config. The official Tailwind docs on container queries are actually pretty good now, which wasn’t always true.
The before code I’m mildly embarrassed about
Here’s the card I had in three places across a dashboard, with viewport-based responsive classes that looked fine on the homepage and broke in a sidebar:
// before: card that lies to itself about its width
export function StatCard({ label, value, trend }) {
return (
<div className="rounded-2xl border p-4 md:p-6 lg:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl md:text-3xl lg:text-4xl font-semibold">{value}</p>
</div>
<p className="mt-2 text-xs text-gray-400">{trend}</p>
</div>
);
}
Drop that card into a 280px sidebar at desktop width and you get the md:flex-row layout squashed into a tiny column, with a 3xl font sitting above a ten-character value. The card doesn’t know it’s small. The viewport is wide, so Tailwind happily applies the big-screen classes.
The hack I used to get around this was passing in a size="sm" prop and branching on it. That scales badly. Three callsites turn into ten, and now the card component has to know about every layout it might land in.
The after code, with @container doing the work
Same component, rewritten to respond to its own width instead of the viewport:
// after: card that reads its own container
export function StatCard({ label, value, trend }) {
return (
<div className="@container rounded-2xl border p-4 @md:p-6 @lg:p-8">
<div className="flex flex-col @md:flex-row @md:items-center @md:justify-between gap-2">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl @md:text-3xl @lg:text-4xl font-semibold">{value}</p>
</div>
<p className="mt-2 text-xs text-gray-400">{trend}</p>
</div>
);
}
Two changes. I added @container to the outer div, which tells the browser “measure this element and use its width for any @ variant children.” Then I swapped every md: for @md:. That’s it. Now when the card lands in the sidebar, the flex-column layout stays. When it lands in a hero slot, the flex-row layout kicks in. The card stopped lying to itself.
The @md breakpoint in Tailwind v4 is 448px of container width by default, not viewport. @sm is 384px, @lg is 512px, @xl is 576px. You can find the full table in the Tailwind v4 container query reference, and you can override the scale if your design system uses different breakpoints.
The footgun that cost me an hour
Here’s the thing nobody tells you: the element with @container on it cannot also use its own @md: classes. Container queries don’t self-reference. So this looks reasonable but does absolutely nothing:
<div class="@container @md:flex-row flex-col">
...
</div>
The @md:flex-row won’t fire even when the div is wide, because the div is its own container, and the container’s width at query time is measured from the child’s perspective. The div isn’t a child of itself.
Fix: wrap it. Put @container on the outer element, and put the @md: classes on the inner content. I lost a solid hour to this the first time, staring at the DevTools and wondering why my selector was never matching.
When I reach for media queries instead
Container queries are not a full replacement. I still use viewport media queries for:
- Page-level layout. If the whole grid goes from 3 columns to 1 when the phone is held vertically, that’s a viewport thing. The grid container’s width is the viewport’s width, so there’s nothing to measure separately.
- Typography scale. Body copy should read well on a phone vs a laptop vs a 4K monitor. That’s a viewport concern, not a container concern. I set
text-base lg:text-lgon the<body>and move on. - Navigation chrome. A hamburger menu that collapses on mobile is keyed to the viewport, not some container.
Container queries earn their keep inside reusable components: cards, list items, form rows, media embeds, anything that gets reused at different sizes. The rule I use now is “if I might drop this thing into more than one width of parent, it gets @container.”
I wrote a longer piece on how v4’s core set of changes fit together in my Tailwind CSS v4 migration guide if you want the broader context on what’s new.
A real example from a client dashboard
On a dashboard I shipped for a client last month (one of the projects I list on my portfolio page), there was a “recent activity” feed that appeared both as a full-width panel on the overview screen and as a skinny right-rail on every detail screen. Same component, two completely different widths.
Before container queries: I passed a variant="compact" prop and conditionally rendered a stripped-down version. Two code paths, two sets of tests, two sets of screenshots in the design system doc.
After: one component. It has @container on the wrapper and uses @sm: to decide whether to show avatars, @md: to decide whether to show timestamps inline or stacked, and @lg: to decide whether to show a right-rail filter. Both callsites just drop the component in. The skinny panel hides avatars automatically because its container is under 384px wide. The wide panel shows everything. I deleted roughly forty lines of prop-plumbing code.
That’s the moment it clicked for me. Container queries aren’t “responsive design 2.0.” They’re “prop-drilling for layout, but the browser does it for you.”
The named containers thing (skip if you’re just starting)
One level deeper: you can name a container and target it by name from anywhere inside. Useful when you have nested containers and need to read a specific ancestor.
<section className="@container/sidebar">
<div className="@container/card rounded-xl">
<p className="@md/sidebar:text-lg">I respond to the sidebar, not the card.</p>
</div>
</section>
The /sidebar suffix names the container. Then @md/sidebar: specifically queries the container named sidebar. I use this maybe 5% of the time, usually for widget grids where a cell’s layout depends on the whole grid’s width, not its own width.
Tailwind’s container query syntax reference covers the whole set if you want to go deeper, including range queries and @max variants.
One thing you can do this week
Open your codebase and grep for components that accept a size or variant prop just to switch layout. Pick one. Rewrite it to use @container and the @md: variants instead. Delete the prop.
That’s it. You’ll know within fifteen minutes whether this pattern is going to save you work. For me it was obvious by the second component I tried. The prop-drilling you stop doing pays for the five minutes of squinting at the docs, and after the third component the muscle memory kicks in.
I still get a small thrill every time I drop a component into a new layout and it just looks right. That used to be a feeling CSS didn’t give me.