Learn how CSS container queries can revolutionize responsive design, allowing components to adapt to their container sizes rather than the viewport.

Container queries have been baseline since late 2023, but a lot of production code still treats them like a demo-only feature. The usual tutorial shows one card, proves the syntax works, and stops right before the part that matters: shipping components that survive grids, sidebars, modals, and nested layouts. If you're already comfortable with @container, the real gap is pattern fluency—knowing when to use it, what breaks first, and how to test it alongside tools like the Viewport Debugger and a grid generator. Here are four patterns you can lift into a codebase today, plus the two rules that prevent the most common first-time failures.
Two mistakes cause most “container queries don’t work” moments.
First: a container cannot style itself. You need a wrapper that establishes the container, then target a child inside the @container rule.
/* WRONG — .card is the container, it can't query itself */
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card { flex-direction: row; } /* won't work */
}
/* CORRECT — .card-wrapper is the container, .card is styled */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card { flex-direction: row; }
}Second: default to container-type: inline-size. For production UI work, that covers the vast majority of cases. size sounds broader, but it also requires containment on both axes, which is exactly the kind of thing that breaks natural height in card layouts, nav blocks, and content-driven components. Use inline-size unless you have a very specific reason not to—and in the patterns below, that reason never shows up.
.component-wrapper {
container-type: inline-size;
container-name: component;
}This is the pattern most developers have seen, but the production version is a little stricter: named container, no viewport breakpoints, and no assumptions about where the component lives. The same card can sit inside a three-column dashboard built with a visual grid builder, a narrow sidebar, or a full-width feature area and adapt based on the slot it gets—not the screen.
<article class="card-wrapper">
<div class="card">
<img src="https://picsum.photos/400/300" alt="Article cover" />
<div class="card__content">
<h3>Container-aware article card</h3>
<p>
This card switches layout based on the width of its parent slot,
not the viewport.
</p>
<a href="/">Read more</a>
</div>
</div>
</article>.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid #d9d9e3;
border-radius: 12px;
background: #fff;
}
.card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 8px;
}
.card__content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card__content h3 {
margin: 0;
font-size: 1.125rem;
}
.card__content p {
margin: 0;
color: #4b5563;
line-height: 1.5;
}
.card__content a {
width: fit-content;
text-decoration: none;
color: #0f62fe;
font-weight: 600;
}
@container card (min-width: 420px) {
.card {
flex-direction: row;
}
.card img {
width: 160px;
aspect-ratio: 1;
}
}Why this works in production: the component doesn’t care whether the page is 1440px wide. It only cares whether its slot is at least 420px wide. That makes it reusable in component libraries, CMS blocks, and dashboard modules without a pile of override media queries.
The first gotcha here is the one from the previous section: keep the container on the wrapper and style the child. The second is also non-negotiable: use inline-size, not size. If you want to experiment with the horizontal/vertical switch before wiring it into your app, the Flexbox Playground is useful for testing the exact flex-direction, gap, and alignment changes.
This is where container queries start paying for themselves in a design system. The same navigation component can render as a vertical stack in a sidebar and as a horizontal row in a header, using the same HTML. Media queries can’t solve this cleanly because the viewport doesn’t tell you which slot the component landed in.
<div class="nav-wrapper">
<nav class="nav" aria-label="Primary">
<a href="/">Dashboard</a>
<a href="/">Projects</a>
<a href="/">Reports</a>
<a href="/">Settings</a>
</nav>
</div>.nav-wrapper {
container-type: inline-size;
container-name: nav-slot;
}
.nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: #f7f8fb;
border-radius: 12px;
}
.nav a {
text-decoration: none;
color: #111827;
padding: 0.625rem 0.875rem;
border-radius: 8px;
}
.nav a:hover {
background: #e8ecf5;
}
@container nav-slot (min-width: 600px) {
.nav {
flex-direction: row;
align-items: center;
gap: 2rem;
}
}The important production detail is the named container: container-name: nav-slot. Without a name, nested containers get confusing fast. Imagine a header component inside a shell layout, with a nav inside the header and a search box inside the nav. If you rely on unnamed queries, you’ll eventually wonder which ancestor is being queried. Naming the container keeps the relationship explicit.
This pattern is especially useful in component libraries because the nav can remain self-contained. Consumers don’t need “sidebar mode” and “header mode” props just to change layout. They place the component, and the container decides. If you want to stress-test the flex switch or tweak spacing at the breakpoint, the flexbox playground is a quick way to validate the direction change before you commit CSS.
This pattern is still missing from most container query tutorials, which is odd because it solves a very real production problem. A data table with 4–5 columns often lives inside a resizable dashboard panel, tab pane, or inspector drawer. On narrow slots, the table becomes unreadable. Historically, teams either duplicated markup for mobile cards or reached for JavaScript. Container queries let you keep one table and reflow it with CSS.
Here’s complete, runnable markup:
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Order</th>
<th>Status</th>
<th>Owner</th>
<th>Total</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Order">#1042</td>
<td data-label="Status">Paid</td>
<td data-label="Owner">A. Rivera</td>
<td data-label="Total">$128.00</td>
<td data-label="Updated">2026-04-11</td>
</tr>
<tr>
<td data-label="Order">#1043</td>
<td data-label="Status">Pending</td>
<td data-label="Owner">J. Kim</td>
<td data-label="Total">$64.00</td>
<td data-label="Updated">2026-04-10</td>
</tr>
<tr>
<td data-label="Order">#1044</td>
<td data-label="Status">Refunded</td>
<td data-label="Owner">M. Singh</td>
<td data-label="Total">$22.50</td>
<td data-label="Updated">2026-04-09</td>
</tr>
</tbody>
</table>
</div>And the CSS:
.table-wrapper {
container-type: inline-size;
container-name: data-panel;
}
.data-table {
width: 100%;
border-collapse: collapse;
font: 14px/1.4 system-ui, sans-serif;
background: #fff;
}
.data-table th,
.data-table td {
padding: 0.875rem 1rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table thead {
background: #f8fafc;
}
@container data-panel (max-width: 640px) {
.data-table thead {
display: none;
}
.data-table,
.data-table tbody,
.data-table tr,
.data-table td {
display: block;
width: 100%;
}
.data-table tbody {
display: grid;
gap: 1rem;
}
.data-table tr {
display: contents;
}
.data-table td {
display: grid;
grid-template-columns: minmax(90px, 120px) 1fr;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 0;
background: #fff;
}
.data-table td:first-child {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding-top: 1rem;
}
.data-table td:last-child {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
padding-bottom: 1rem;
box-shadow: 0 0 0 1px #e5e7eb;
}
.data-table td::before {
content: attr(data-label);
font-weight: 600;
color: #374151;
}
}A few practical notes matter here:
display: contents on tr lets the cells participate in the new layout without extra wrapper markup.data-label on each td gives you a readable label in the stacked version.The big win is that the table can live in a wide analytics page and look like a normal table, then move into a narrow side panel and become a card list automatically. No viewport breakpoint can describe that as accurately as the container can.
vwViewport-based fluid type is fine for page-level heroes. It’s the wrong default for reusable components. If a hero banner appears full width on a landing page and inside a modal on a product page, vw ties the type scale to the browser window, not the component’s actual width. Container units fix that.
<section class="hero-wrapper">
<div class="hero">
<h1>Component-scaled heading</h1>
<p>
This heading scales with the width of the hero component, whether the
component is full width or embedded in a smaller surface.
</p>
</div>
</section>.hero-wrapper {
container-type: inline-size;
container-name: hero;
}
.hero {
padding: 1.5rem;
border-radius: 16px;
background: linear-gradient(135deg, #111827, #1f2937);
color: white;
}
.hero h1 {
margin: 0 0 0.75rem;
/* Scales to the component width, not the viewport */
font-size: clamp(1.5rem, 5cqi, 3rem);
}
.hero p {
margin: 0;
max-width: 60ch;
color: #d1d5db;
}
/* Compare to viewport-relative (don't use for reusable components): */
/* font-size: clamp(1.5rem, 5vw, 3rem); */cqi is the useful unit here because it tracks the container’s inline size. That means the heading grows when the component has room and stays sane when the component is embedded in a smaller context. It’s one of the cleanest examples of container queries solving a component problem instead of a page problem.
If you want to tune the exact values, this clamp() calculator is handy for generating ranges and comparing the feel of your min/preferred/max choices. The important shift is conceptual: for reusable components, scale typography against the component, not the viewport.
Container queries are not a replacement for every responsive technique you already use. They’re the missing layer between “the whole page” and “this component instance.” Media queries still belong to macro layout, device-level conditions, and user preferences: @media print, prefers-color-scheme, prefers-reduced-motion, and broad page shells that really do depend on the viewport.
The practical rule is simple: if the decision depends on where a component is placed, use a container query. If the decision depends on the environment around the entire document, use a media query. That split keeps your CSS easier to reason about and stops component styles from accumulating viewport-specific hacks over time.
Pick one component in your current project—usually a card or nav is enough—and rewrite it using Pattern 1 or Pattern 2. Then test the component in multiple slots, verify the layout behavior with the Flexbox Playground, and compare component behavior against actual viewport changes with the Viewport Debugger.
Which of these are you most likely to ship first: the card, the nav, the table, or the cqi typography pattern?