Responsive Component Design: Container Queries Over Media Queries
Container queries let components respond to their own space, not the viewport. Here's why you should drop media queries for component-level responsiveness.
The Media Query Trap You've Already Fallen Into
Here's the dirty secret no one mentions in CSS tutorials: media queries were never really designed for components. They were designed for pages — full-width layouts where you're reacting to the browser viewport, not to where a component actually lives. And that distinction? It matters enormously once your design system grows past a handful of reusable pieces.
Think about a card component. You want it to be one-column when it's narrow and two-column when it's wide. Sounds simple. But with @media, you're writing breakpoints tied to the viewport — so your card looks fine at 1200px viewport width, then breaks immediately when you drop it into a 400px sidebar at that same viewport width. You've coupled your component's layout to the page layout. That's the trap.
Look, this isn't a new problem. Developers have been hacking around it since Bootstrap 3. We've used JavaScript resize observers, CSS Grid with auto-fill, and enough calc() expressions to make anyone's eyes water. Container queries arrived in Chrome 105 (shipped August 2022) and have had solid cross-browser support since early 2023. There's no real excuse not to use them in 2026.
That said, unlearning the media query reflex is harder than it sounds. Your fingers still type @media (min-width: 768px) out of muscle memory. This article is about building that new reflex — writing components that own their responsive behavior regardless of where you drop them.
Container Queries: The Actual Mechanics
The setup is two lines. You declare a containment context on the parent, then write @container rules on the children. That's it.
/* Step 1: declare the container */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* Step 2: respond to it */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
gap: 16px;
}
}
@container card (min-width: 640px) {
.card {
grid-template-columns: 200px 1fr;
}
}container-type: inline-size is the important bit — it tells the browser to track the container's inline (horizontal) size without triggering layout containment on the block axis, which would break natural height flow. You can also use container-type: size if you need to query both axes, but honestly that's rarely what you want for UI components. Stick with inline-size 90% of the time.
Worth noting: you don't have to name your containers. Anonymous containers work fine if you're only nesting one level deep. But once you have containers inside containers — say a sidebar component inside a layout component — naming them prevents the inner @container rules from accidentally matching the wrong ancestor.
One more thing — container-name is composable. A single element can have multiple names separated by spaces: container-name: card sidebar. This lets you write rules that only fire in specific layout contexts, which is genuinely useful for shared components that live in very different contexts across your app.
Replacing Your Existing Media Query Patterns
The migration isn't a full rewrite. Most media query breakpoints map cleanly to container query breakpoints, you just need to shift where the context is declared. Here's a practical before/after for a common card pattern you'll recognize immediately.
/* Before — viewport-coupled, breaks in sidebars */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 600px) {
.card {
flex-direction: row;
}
}
/* After — self-contained, works anywhere */
.card-container {
container-type: inline-size;
}
.card {
display: flex;
flex-direction: column;
}
@container (min-width: 360px) {
.card {
flex-direction: row;
}
}Notice the breakpoint changed from 600px to 360px. That's intentional — you're now measuring the component's own space, not the viewport. A card that gets 360px of its own container is actually pretty wide. Don't blindly copy your old breakpoint values across; rethink them from the component's perspective.
In practice, I'd suggest auditing your existing breakpoints and asking: "Is this breakpoint reacting to the page or to the component?" Page-level stuff — navigation collapsing, sidebar toggling, hero image stacking — can stay as @media. Everything at the component level should move to @container. That split is the mental model worth internalizing.
Honestly, the migration usually takes an afternoon for a medium-sized component library. The bigger investment is updating your Storybook stories to test components at various container widths rather than various viewport widths — which is actually more useful anyway, since it decouples your visual tests from a specific layout assumption. If you're building out your component library, the Empire UI's component patterns can give you a solid reference for how this layering typically shakes out.
Container Query Units: cqi, cqw, cqb, cqh
Container queries didn't just bring @container rules — they also shipped a set of container-relative length units. These are the equivalent of viewport units (vw, vh) but scoped to the nearest containment context instead of the viewport. You've probably seen cqi and cqw mentioned in passing without much explanation.
.card-container {
container-type: inline-size;
}
.card__title {
/* Font scales with the container, not the viewport */
font-size: clamp(1rem, 4cqi + 0.5rem, 2rem);
}
.card__image {
/* Always 40% of the container's inline size */
width: 40cqi;
}cqi = 1% of the container's inline size. cqw = 1% of the container's width (same as cqi for horizontal writing modes, which is basically everything). cqb = 1% of the container's block size. cqh = 1% of the container's height. Quick aside: cqmin and cqmax also exist — they're the smaller/larger of cqi and cqb, equivalent to vmin/vmax in viewport land.
The killer use case is fluid typography scoped to components. Instead of one global clamp() based on viewport width, each component can have its own fluid type scale that responds to its actual rendered size. Drop that card into a 300px sidebar and the font scales down to 16px. Drop it into a 900px main content area and it scales up to 24px. No extra CSS needed. Compare this to responsive typography systems that still rely on viewport units — container-relative units give you a genuinely superior approach for isolated components.
That said, don't go overboard. Using cqi units for everything creates cognitive load when debugging — it's harder to predict rendered sizes when multiple containment contexts are stacked. Use them for genuinely fluid elements like typography and spacing that should scale with the component, and keep fixed px or rem values for things like border widths and icon sizes that shouldn't scale.
Style Queries: The Part Most Developers Haven't Tried Yet
Container queries have a lesser-known sibling: style queries. Instead of querying a container's size, you query the value of a CSS custom property on it. This lets you create truly theme-aware components without JavaScript, class toggling, or data attributes. It's shipping in Chrome 111+ and has growing support — worth experimenting with now.
.theme-wrapper {
container-type: style;
--theme: dark;
}
@container style(--theme: dark) {
.card {
background: #1a1a2e;
color: #e2e8f0;
border-color: rgba(255, 255, 255, 0.1);
}
}
@container style(--theme: light) {
.card {
background: #ffffff;
color: #1a202c;
border-color: rgba(0, 0, 0, 0.1);
}
}The practical win here is component-level theming without prop drilling. A parent sets --theme: dark and every nested component that has style query rules picks it up automatically. You're not passing a dark prop through three layers of JSX. You're not adding a .dark class to 15 components manually. The cascade does the work.
Worth noting: style queries currently only work with CSS custom properties, not computed values or shorthand properties. You can't write @container style(color: red) — only @container style(--color: red). That's actually fine for most theming use cases, since custom properties are exactly what you'd use for theme tokens anyway. If you've already set up a CSS custom properties system, style queries layer on top cleanly with almost no extra work.
Honestly, style queries feel like the feature that's going to quietly become a design system staple over the next two years, the way CSS variables went from "experimental" to "obviously correct" between 2018 and 2021. Get comfortable with them now before everyone else does.
Building Components That Work Anywhere
Here's a complete, production-realistic component pattern combining size queries, style queries, and container units into one coherent piece. This is the kind of thing you'd actually ship — not a toy example.
// ProductCard.tsx
export function ProductCard({ product }: { product: Product }) {
return (
// Wrapper sets the containment context
<div className="product-card-container">
<article className="product-card">
<div className="product-card__image">
<img src={product.image} alt={product.name} />
</div>
<div className="product-card__body">
<h3 className="product-card__title">{product.name}</h3>
<p className="product-card__price">{product.price}</p>
<button className="product-card__cta">Add to cart</button>
</div>
</article>
</div>
);
}/* product-card.css */
.product-card-container {
container-type: inline-size;
container-name: product-card;
}
.product-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border-radius: 8px;
background: var(--card-bg, #ffffff);
}
.product-card__title {
/* Fluid: 14px at 200px container, 20px at 500px container */
font-size: clamp(0.875rem, 2cqi + 0.5rem, 1.25rem);
}
/* Layout shift at 380px container width */
@container product-card (min-width: 380px) {
.product-card {
flex-direction: row;
align-items: center;
}
.product-card__image {
flex: 0 0 120px;
}
.product-card__body {
flex: 1;
}
}
/* Richer layout at 560px+ */
@container product-card (min-width: 560px) {
.product-card {
padding: 24px;
gap: 24px;
}
.product-card__image {
flex-basis: 200px;
}
.product-card__cta {
margin-top: 12px;
}
}Drop this card into a 3-column grid, a 400px sidebar, a full-width hero section, or a modal — it adapts correctly every time. Zero JS. Zero extra props. The component owns its responsiveness entirely. This is exactly the kind of self-contained component architecture that makes a design system genuinely reusable rather than context-dependent.
When you're building a full component library — or browsing one like Empire UI — this pattern is what separates components you can actually drop anywhere from components that only work in the layout they were designed for. That distinction is underrated. Components that require specific viewport context are secretly just page-specific code wearing a reusability costume.
When To Still Use Media Queries
Container queries don't replace media queries entirely. Some things genuinely are viewport-level concerns and should stay that way. Navigation is the obvious one — whether your nav collapses to a hamburger menu at 768px is a full-page layout decision, not a component-level decision. Same with sidebar visibility, full-page hero layouts, and print styles.
A useful mental test: "If I dropped this component into a different layout, would I want this rule to still fire?" If yes, it's component-level — use @container. If the rule only makes sense at the page level, keep @media. You'd be surprised how often asking this question reveals that you've been writing page-level CSS inside component files. That's where the mess creeps in.
There's also a performance angle. Container queries aren't free — the browser has to track containment contexts and re-evaluate rules when container sizes change. For most apps this is imperceptible, but if you have hundreds of cards rendering in a virtualized list that's resizing constantly, you might feel it. Quick aside: container-type: inline-size is lighter than container-type: size because it doesn't trigger full containment on both axes. Use the minimum containment you need.
That said, the performance cost of container queries at normal scale is so small it shouldn't influence your architecture decisions. Don't let hypothetical performance concerns keep you writing viewport-coupled components. Write the correct abstraction, then measure if you actually have a problem. You almost certainly won't.
FAQ
Yes. @container with inline-size has been supported in Chrome 105+, Firefox 110+, and Safari 16+ since 2022–2023. Global browser support sits above 93% as of 2026. You can use them in production without a polyfill.
Not always. If a component is already wrapped by a parent element you control — a grid cell, a list item — you can put container-type on that wrapper instead of adding an extra DOM node. The goal is zero unnecessary wrapper divs, not zero containment contexts.
Yes, though syntax varies. In styled-components you write the @container block inside the component's template literal, same as any other at-rule. The containment declaration still needs to live on a parent wrapper, so you'd typically export both a CardContainer and a Card styled component.
inline-size only tracks the container's horizontal dimension and is what you want 90% of the time. size tracks both axes but applies full layout containment, which means the container's height won't grow to fit its children by default. Use size only when you genuinely need to query height.