Tailwind Container Queries: Responsive Components Without Media Queries
Container queries let components respond to their own size, not the viewport. Here's how to use them in Tailwind v4 to build truly portable, context-aware UI.
Why Media Queries Keep Failing Reusable Components
Honestly, media queries were never designed for component libraries. They tell a component about the viewport — but a component doesn't care about the viewport. It cares about how much space it actually has. Drop a card into a 300px sidebar and into a 900px main area, and a media query can't tell the difference.
This is the core problem. You write .card { @media (min-width: 768px) { ... } } and it looks fine in isolation. Then someone embeds your card in a two-column layout and everything breaks at 800px viewport width because your card only has 380px of available space. You can't fix that with a media query.
Container queries solve this by letting elements observe their own containing block. A card can say: 'when my container is at least 400px wide, go horizontal.' It doesn't matter where on the page that container sits. This is the mental shift — from thinking about the page to thinking about the component.
Setting Up Container Queries in Tailwind v4
In Tailwind v4.0.2, container queries are built into the core — no extra plugin needed. Earlier in Tailwind v3 you had to install @tailwindcss/container-queries separately and add it to your plugins array. That's gone now. If you're still on v3, you'll need the plugin.
The setup is minimal. Mark a parent element as a container using the @container utility. Then use container-based breakpoints on children with the @sm:, @md:, @lg: prefixes — or custom sizes like @[400px]:. That's it. No config changes required.
One thing to watch: container query breakpoints look visually similar to responsive breakpoints, but they're semantically different. md:flex responds to the viewport at 768px. @md:flex responds to the container. Mixing them up is an easy bug to introduce, especially when migrating older components. Be explicit with naming in your codebase.
The @container Utility and Container Types
To define a container, you add the @container class to a wrapper element. By default this creates an inline-size container, which means it tracks width. You can also name containers with @container/sidebar syntax, which lets deeply nested children query a specific ancestor rather than the nearest container.
Here's a real example. A product card that collapses to stacked layout in tight spaces and goes side-by-side when it has room:
<div className="@container rounded-xl border border-white/10 p-4">
<div className="flex flex-col @[420px]:flex-row gap-4">
<img
src={product.image}
alt={product.name}
className="w-full @[420px]:w-40 @[420px]:shrink-0 rounded-lg object-cover"
/>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold">{product.name}</h3>
<p className="text-sm text-white/60 @[420px]:line-clamp-3">{product.description}</p>
<span className="text-xl font-bold mt-auto">{product.price}</span>
</div>
</div>
</div>Notice the @[420px]: syntax for an arbitrary container width. This card will stack vertically in a 300px sidebar and go horizontal in a 600px main column — without a single media query or JavaScript resize observer.
Named Containers and Multi-Level Nesting
Named containers are where things get interesting. You'll use them when a component is nested several levels deep and needs to query a specific ancestor — not just the nearest one. Think of a badge inside a card inside a sidebar. The badge might need to respond to the sidebar width, not the card.
{/* Outer sidebar container */}
<aside className="@container/sidebar w-64">
<div className="@container/card rounded-xl p-4">
{/* This queries the sidebar, not the card */}
<span className="text-xs @sidebar/[500px]:text-sm font-medium">
{label}
</span>
{/* This queries the card */}
<div className="hidden @card/[300px]:block">
<ExpandedDetails />
</div>
</div>
</aside>The syntax is @{containerName}/[size]: for arbitrary values, or @{containerName}/{breakpoint}: for named breakpoints. It reads a bit verbose at first, but the explicitness is the point — you always know exactly which ancestor a variant is responding to.
Custom Container Breakpoints and the @theme Layer
Default container breakpoints in Tailwind v4 follow the same scale as responsive breakpoints: @sm is 640px, @md is 768px, @lg is 1024px, and so on. But component design often needs finer-grained control. A card component might need breakpoints at 320px, 480px, and 600px — values that don't align with the default scale.
You can define custom container breakpoints in your @theme block:
@import "tailwindcss";
@theme {
--container-xs: 280px;
--container-card-sm: 380px;
--container-card-md: 520px;
--container-card-lg: 720px;
}After that, @card-sm: works as a container query variant in your HTML. This pairs well with a design token system — if you're also using Tailwind OKLCH color tokens or theming variables, keeping your container sizes in @theme keeps everything in one place and makes the system easier to reason about.
For one-off sizes you don't want to formalize, the arbitrary bracket syntax @[480px]: is perfectly fine. Use named breakpoints when a value appears across multiple components. Use arbitrary when it's truly specific to one component.
Container Queries vs Responsive Breakpoints: When to Use Which
The honest answer: use container queries for components, use responsive breakpoints for layout. A navigation bar deciding whether to show a hamburger menu? That's layout — responsive breakpoints make sense. A data card deciding whether to show two columns or one? That's a component decision — container queries.
Where does it get tricky? Page-level sections. A hero section is arguably layout, but it might contain components that need container queries internally. You can absolutely combine them. The outer section uses md:grid-cols-2 to split at 768px viewport. Inside that grid, each column uses @container so its contents can adapt to whatever width the grid gives them. They don't conflict — they operate at different levels.
If you've been reading about Tailwind v4 features you'll know the new cascade layer architecture makes mixing these approaches much cleaner. There's no specificity war between @media and @container rules, since they operate on completely different axes.
What about JavaScript-driven approaches like ResizeObserver? They work, but they're slower. Container queries run in CSS — no JS parsing, no layout thrashing, no requestAnimationFrame tricks. For most UI patterns, container queries are the right default. Reach for ResizeObserver only when you need to react to size in JavaScript logic, not just in styles.
Building a Reusable Card Component with Container Query Variants
Let's put this together in a practical component. This card is designed to work in any context — a 2-column product grid, a sidebar widget list, a full-width feature section. No props needed to switch layouts. The container does the work.
interface ContentCardProps {
title: string
description: string
image: string
tag?: string
href: string
}
export function ContentCard({ title, description, image, tag, href }: ContentCardProps) {
return (
<div className="@container">
<a
href={href}
className="
group flex flex-col @[400px]:flex-row
gap-4 @[400px]:gap-6
rounded-2xl border border-white/10
bg-white/5 backdrop-blur-sm
p-4 @[400px]:p-6
hover:border-white/20 transition-colors
"
>
<div className="
relative w-full @[400px]:w-48 @[400px]:shrink-0
aspect-video @[400px]:aspect-square
rounded-xl overflow-hidden
">
<img
src={image}
alt={title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{tag && (
<span className="absolute top-2 left-2 text-xs font-medium px-2 py-1 rounded-full bg-black/60 text-white">
{tag}
</span>
)}
</div>
<div className="flex flex-col gap-2 min-w-0">
<h3 className="font-semibold text-base @[400px]:text-lg leading-tight">
{title}
</h3>
<p className="text-sm text-white/60 @[400px]:line-clamp-3 line-clamp-2">
{description}
</p>
<span className="mt-auto text-sm font-medium text-white/80 group-hover:text-white transition-colors">
Read more →
</span>
</div>
</a>
</div>
)
}This component genuinely doesn't know or care about its context. Drop it in a 3-column grid at 1200px — each card is ~380px, so they'll go horizontal. Drop the same component in a 280px mobile sidebar and they'll stack. Zero conditional rendering, zero prop drilling for layout variants.
This pattern pairs naturally with Tailwind glassmorphism effects if you're building a dark UI — the bg-white/5 backdrop-blur-sm already handles the glass aesthetic, and container queries handle the structural adaptation.
Common Pitfalls and Browser Support
Browser support is solid as of late 2025. Chrome 105+, Firefox 110+, Safari 16+ all support container queries. If you need to support older browsers, you'll want to check Can I Use and possibly add a fallback with the stacking layout as the default. The stacking layout is usually the mobile default anyway, so in most cases your baseline CSS works fine and container queries are a progressive enhancement.
The most common pitfall: forgetting that the container element itself doesn't respond to container queries. Only its descendants do. So if you put @container and @[400px]:flex-row on the same element, the container query won't work. You need a wrapper. It's a one-element overhead — worth it.
Another one: container size constraints. A container's size is determined by its own layout context. If you put @container on an element with width: fit-content and no explicit size, it'll shrink-wrap its content and the container query might never fire. You'll need to either set an explicit width or let the container be constrained by its parent's layout. This catches people off guard when using @container inside flex or grid items — make sure the parent gives the container a defined size.
If you're curious how container queries interact with JavaScript-driven theming — say, dark/light mode switching in React — the answer is: they don't conflict at all. Container queries are purely structural. Theme switching is about custom property values. They operate independently and compose cleanly.
FAQ
No. Container queries are built into Tailwind v4 core. If you're on Tailwind v3, you need @tailwindcss/container-queries from npm and add it to the plugins array in tailwind.config.js. In v4.0.2 and above, just use @container and the @sm:, @md:, @[arbitrary]: variants directly.
md: is a responsive variant — it applies at viewport widths of 768px or more. @md: is a container query variant — it applies when the nearest @container ancestor is 768px or more wide. They look similar but operate completely differently. Mixing them up is a common source of bugs when migrating components.
Yes. Apply @container/name to the ancestor, then use @name/[size]: or @name/{breakpoint}: on descendant elements. For example: @container/sidebar on the sidebar element, then @sidebar/[500px]:text-lg on a child deep inside the sidebar. This targets that specific ancestor regardless of how many @container elements are in between.
No — a container can't query itself. Only its descendants can. You always need at least one wrapper level. Put @container on the outer wrapper, then use container query variants on inner elements. Applying both @container and @[400px]:flex-row to the same div won't work.
Marginally, because the browser has to track each container's size. In practice for typical UI components the difference is unmeasurable. They're vastly faster than JavaScript-based resize observers since everything runs in the CSS engine before paint. Don't avoid container queries for performance reasons — use them freely for layout-adaptive components.
Define them in the @theme block in your CSS entry file: @theme { --container-card-sm: 380px; }. After that, @card-sm: works as a container query variant. For one-off sizes you don't want to formalize, use arbitrary values like @[380px]: instead.