Container Queries for Components: Component-Driven Responsive Design
Container queries let components respond to their own space, not the viewport. Here's how to build genuinely reusable, self-aware React components in 2026.
The Problem Media Queries Never Actually Solved
Here's the thing about media queries — they were never really designed for components. They describe the viewport. Your card component doesn't live in the viewport directly; it lives in a sidebar, a modal, a dashboard grid, a full-width hero. Same component, four wildly different widths. Media queries can't know which one.
Honestly, this mismatch has been responsible for more brittle CSS than anything else in the last decade. You'd write .card { ... } then override it at @media (max-width: 768px) and think you're done. Then a designer drops that card into a two-column layout at 1400px and it looks like it was designed in 2009. The breakpoint is right, but the container is narrow — nothing you wrote accounts for that.
Container queries landed in Chrome 105 (2022), Firefox 110, and Safari 16. By 2026, browser support sits at over 96% globally. There's no polyfill story you need to worry about anymore. This is just CSS now.
The shift in thinking matters more than the syntax. Instead of asking 'how wide is the screen?', you're asking 'how wide is the space this component actually occupies?' That question has a genuinely correct answer.
The Syntax: containment, container-type, and @container
Three properties to know. First: container-type. Set this on the parent wrapper — not the component itself. container-type: inline-size tells the browser to track the element's inline (horizontal) size and expose it to container queries. You can also use size to track both axes, but inline-size covers 90% of real use cases.
Second: container-name. Optional, but you'll want it when you have nested containers. Named containers let you query a specific ancestor by name instead of the nearest one.
/* The wrapper — set this once per layout slot */
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
.main-content {
container-type: inline-size;
container-name: main;
}Third: @container. This is where you write your responsive rules, inside the component's stylesheet. It looks exactly like @media but references the container instead of the viewport.
``css
.card {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
padding: 16px;
}
/* When the card's container is at least 480px wide */
@container (min-width: 480px) {
.card {
grid-template-columns: auto 1fr;
padding: 24px;
}
}
@container sidebar (min-width: 300px) {
.card__title {
font-size: 1.25rem;
}
}
``
Worth noting: the breakpoint values in @container are relative to the container element, not the document root. A min-width: 480px container query fires when the .sidebar div is 480px wide — doesn't matter if the viewport is 375px or 2560px.
Building a Self-Aware React Component
The beauty of container queries is that the component's CSS is fully self-contained. No props for layout mode, no JavaScript measuring, no ResizeObserver hacks. The component decides its own layout based on the space it gets.
Here's a product card that renders a stacked layout when narrow and a horizontal layout when wide — automatically, with zero JavaScript:
``tsx
// ProductCard.tsx
export function ProductCard({ product }: { product: Product }) {
return (
<article className="product-card">
<img
src={product.image}
alt={product.name}
className="product-card__image"
/>
<div className="product-card__body">
<h2 className="product-card__title">{product.name}</h2>
<p className="product-card__price">${product.price}</p>
<button className="product-card__cta">Add to cart</button>
</div>
</article>
);
}
`
`css
/* product-card.css */
.product-card {
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 12px;
overflow: hidden;
background: var(--surface);
}
.product-card__image {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
/* horizontal layout when container >= 400px */
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
.product-card__image {
width: 160px;
aspect-ratio: 1;
}
}
``
Notice the parent grid never touches the card's internal layout. You can drop <ProductCard /> into a 1-column mobile grid, a 3-column desktop grid, or a 320px sidebar — it handles itself. That's the whole pitch.
In practice, I wrap every reusable layout component in a dedicated container div rather than relying on whatever the parent happens to be. It gives you a stable, predictable containment point.
``tsx
// The slot wrapper — set this in your layout, not in the component
<div style={{ containerType: 'inline-size' }}>
<ProductCard product={product} />
</div>
``
Quick aside: Tailwind 3.3+ ships @container support via the @tailwindcss/container-queries plugin. The syntax is clean — @lg:flex-row, @sm:text-sm — but you still need to set container on the parent element. Both approaches (plain CSS and Tailwind) work fine; pick whichever fits your project.
Container Queries in a Design System
This is where container queries genuinely change the game. In a design system, components are consumed in contexts you don't control. Someone drops your <StatsWidget /> into a narrow drawer. Another team puts it in a full-bleed hero. A third team puts it in a dashboard tab that resizes dynamically. With media queries, you'd need layout-mode props — size="compact", size="full" — and every consumer has to know which to pick.
With container queries, the component makes that call internally. Your API surface shrinks. Documentation gets simpler. Bugs from mismatched prop values disappear. That's a real win, not a theoretical one.
Look at how this applies to something like a navigation block. At 240px it shows icons only. At 280px it shows icons plus short labels. At 360px it shows the full nav with section headings. All of that lives inside the component's CSS file. The consuming page just renders <Sidebar /> and doesn't care about its width.
``css
.nav-item__label { display: none; }
.nav-section__heading { display: none; }
@container (min-width: 280px) {
.nav-item__label { display: block; font-size: 0.75rem; }
}
@container (min-width: 360px) {
.nav-section__heading { display: block; }
.nav-item__label { font-size: 0.875rem; }
}
``
If you're building or consuming a component library — like Empire UI — container queries are what make components genuinely portable. Components that adapt to their slot, not the screen, compose cleanly into grids, drawers, modals, and responsive templates without needing per-context overrides. Browse the templates to see how adaptive components stack in real layouts.
One more thing — container queries also enable container query units. cqi is 1% of the container's inline size. cqw is 1% of the container's width. These let you scale font sizes, gaps, and padding proportionally to the container rather than the viewport. font-size: clamp(0.875rem, 4cqi, 1.25rem) is genuinely useful.
Container Queries vs ResizeObserver: When to Use Which
You might be thinking: I've been doing this with ResizeObserver for years. Why switch? The answer isn't always 'switch completely' — it's knowing what each tool is actually good for.
Container queries are pure CSS. They run before JavaScript, they don't cause layout thrash, and they don't need a useEffect cleanup. For purely visual layout changes — column direction, image size, font scale, spacing — there's no reason to touch JavaScript. Container queries are faster and simpler.
ResizeObserver wins when you need to react to size changes in JavaScript. Virtualized lists, canvas redraws, third-party charting libraries that need explicit pixel dimensions, lazy-loading thresholds — these still belong in JS. The rule of thumb: if you're changing a CSS property, use @container. If you're calling a function, use ResizeObserver.
``tsx
// Still valid for JS-driven size reactions
useEffect(() => {
const observer = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
chart.resize(width, width * 0.5625); // 16:9
});
observer.observe(chartRef.current!);
return () => observer.disconnect();
}, []);
``
That said, some teams are shipping hybrid patterns — container queries handle the CSS layout, a single ResizeObserver at the top of the component tree dispatches a CustomEvent with the current size bucket ('sm' | 'md' | 'lg'), and child components subscribe to that event for JS-driven behavior. It keeps the rendering layer clean and the logic layer explicit.
Common Gotchas and How to Avoid Them
A component can't query its own size — only an ancestor's. This trips people up constantly. If you put container-type: inline-size on .card itself and then write @container (min-width: 400px) { .card { ... } }, it won't work. The container and the queried element can't be the same element. You need a wrapper.
``tsx
{/* Wrong — can't query yourself */}
<div style={{ containerType: 'inline-size' }} className="card">
...
</div>
{/* Right — query the parent */}
<div style={{ containerType: 'inline-size' }}>
<div className="card">...</div>
</div>
``
Containment affects layout. Setting container-type: inline-size also applies overflow: hidden behavior to the element in some edge cases, and it creates a new stacking context. If your component uses position: sticky or relies on overflow-visible for dropdowns, test carefully. container-type: size is more restrictive — avoid it unless you actually need to query height.
Named containers don't bubble — querying @container sidebar only works if .sidebar is an actual ancestor of the element in the DOM. If the component gets teleported to a portal (React Portal, <Teleport> in Vue), the named container lookup breaks. Unnamed container queries find the nearest containerized ancestor regardless of portals, so they're safer for portaled content.
One more gotcha: nested containers each query their own nearest ancestor container, not the outermost one. This is usually what you want, but it can surprise you when a deeply nested component queries a container you didn't intend. Naming your containers is the cleaner solution — it makes the relationship explicit. If you're designing adaptive component systems and want to see how smart CSS composition works in practice, check out the glassmorphism generator for a live example of how layout-aware styling layers together.
Migration Strategy: From Media Queries to Container Queries
You don't have to rewrite everything. Start with components that are reused in more than two different layout contexts. Those are the ones currently requiring the most per-context CSS overrides and the most awkward prop APIs. They're also the ones that will benefit most from container queries.
The migration is mostly mechanical. For each component, identify the breakpoints where layout changes. Add a container wrapper. Move the @media rules to @container rules, and adjust the breakpoint values to match the component's typical rendered size rather than the viewport.
``css
/* Before */
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* After */
@container (min-width: 480px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
``
Note the breakpoint shifted from 768px to 480px. That's not a mistake — the component typically renders in a content area around 60-70% of the viewport, so the container breakpoint is lower than the viewport breakpoint was.
Keep your existing viewport-level @media queries for macro layout shifts — changing the overall page grid, showing/hiding the sidebar, switching between mobile and desktop navigation. Container queries operate inside those macro layouts. The two approaches complement each other; you're not replacing one with the other entirely.
In practice, the end state you're aiming for is: page-level layouts controlled by media queries or grid/flex parent rules, component-level layouts controlled by container queries. Clean separation of concerns. Components become truly portable. That's a design system architecture that actually scales.
FAQ
Yes. Container queries have shipped in Chrome 105+, Firefox 110+, and Safari 16+, giving you about 96% global coverage. You don't need a polyfill for new projects.
Yes, via the official @tailwindcss/container-queries plugin. It adds @sm:, @md:, @lg: variants that fire based on the nearest ancestor with the container class.
inline-size tracks width only and is what you want almost always. size tracks both dimensions but applies stronger containment that can break sticky positioning and overflow behavior.
An element can't query its own container — you need a parent wrapper with container-type set. Move the containment one level up and the query will work.