Container Queries
Style components based on their container's size instead of the viewport.
The Problem
Media queries respond to the viewport width, not the width of the component's container. When a component is placed in a sidebar, a modal, or any constrained layout, viewport-based media queries cannot adapt the component's layout to its actual available space. AI agents almost always reach for @media queries for component-level responsiveness, ignoring container queries entirely.
The Solution
CSS Container Queries (@container) allow components to respond to the size of their parent container rather than the viewport. This makes components truly reusable across different layout contexts. Container queries are Baseline 2023 and supported in all modern browsers.
Safari 16 / iOS 16 required
Container queries need Safari 16 (iOS 16, released September 2022) or later. Older Safari does not error or crash on @container — it silently ignores the entire rule block, so any element inside only ever receives its un-queried base styles.
If a project's browserslist targets older iOS (for example ios >= 15.4), treat those un-queried base styles as the acceptable standalone result for that slice of visitors, not as a placeholder waiting for a fallback. Tailwind ships no polyfill for container queries — there is no automatic degrade to fall back on.
Setting Up a Container
A parent element must be declared as a containment context using container-type. The most common value is inline-size, which enables queries based on the container's inline (horizontal) dimension.
.card-wrapper {
container-type: inline-size;
}Querying the Container
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
}
}Basic Container Query
The iframe in this demo acts as the container boundary. Use the viewport buttons to see the card layout adapt to the container width.
Named Containers
When containers are nested, @container queries match the nearest ancestor with container-type set. To target a specific container, use container-name and reference it in the query.
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
.main-content {
container-type: inline-size;
container-name: main;
}
/* Only responds to the sidebar container */
@container sidebar (max-width: 300px) {
.nav-list {
flex-direction: column;
}
}The shorthand container property combines both:
.sidebar {
container: sidebar / inline-size;
}The Tailwind Way
Tailwind CSS v4 has native support for container queries — no plugin required. Each utility below maps directly onto one of the plain-CSS patterns already on this page.
Marking a Container
The @container class is the utility equivalent of container-type: inline-size — the same thing the "Setting Up a Container" section above does with plain CSS.
<div class="@container">
<div class="flex flex-col @sm:flex-row @lg:gap-8">
<!-- ... -->
</div>
</div>For a block-size (height-based) container, Tailwind has no static utility — use the arbitrary-value form @container-[size] instead, the equivalent of container-type: size.
The Container Query Variant Scale
@sm:, @md:, @lg:, and the rest are container-width variants. They are a completely different scale from the sm: / md: / lg: / xl: / 2xl: viewport-width breakpoints used everywhere else in Tailwind — same-looking prefixes, different axis, different pixel values. Tailwind's default container scale runs from @3xs to @7xl:
| Variant | Minimum container width |
|---|---|
@3xs | 16rem (256px) |
@2xs | 18rem (288px) |
@xs | 20rem (320px) |
@sm | 24rem (384px) |
@md | 28rem (448px) |
@lg | 32rem (512px) |
@xl | 36rem (576px) |
@2xl | 42rem (672px) |
@3xl | 48rem (768px) |
@4xl | 56rem (896px) |
@5xl | 64rem (1024px) |
@6xl | 72rem (1152px) |
@7xl | 80rem (1280px) |
Compare that to the viewport breakpoint scale: sm is 40rem (640px), md is 48rem (768px), lg is 64rem (1024px), xl is 80rem (1280px), 2xl is 96rem (1536px). @md: and md: do not activate at the same width — they are not the same scale wearing different notation.
Theme Tokens: --container-* vs. --breakpoint-*
The two scales come from two independent theme namespaces:
--breakpoint-*generates thesm:/md:/ ... viewport variants.--container-*generates the@sm:/@md:/ ... container-query variants.
A normal @import "tailwindcss"; ships both namespaces fully populated with their defaults, and the two do not interact — redefining --breakpoint-* (say, adding a custom 3xl viewport breakpoint) has no effect on --container-*, and vice versa.
The trap shows up in theme-reset projects: setups that wipe Tailwind's default theme (--*: initial; inside @theme) and rebuild only the tokens they need. It's easy to redefine --breakpoint-* for a custom viewport scale and never redefine --container-*:
@import "tailwindcss";
@theme {
--*: initial;
--breakpoint-sm: 30rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
/* --container-* was never redefined */
}In this setup, @sm:, @md:, and every other named container variant has no matching theme value. Nothing errors — the variant just never generates a rule, so a class like @sm:flex-row silently does nothing. There are two ways out:
Skip the named scale and use arbitrary values instead:
@min-[384px]:flex-row,@max-[900px]:gap-2.Define the
--container-*tokens the project actually uses:
@theme {
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
}Named Containers in Tailwind
@container/label names the containment context; @sm/label: (and the rest of the scale) target it — the utility equivalent of the container-name plus @container name (...) pattern from the "Named Containers" section above.
<div class="@container/sidebar">
<nav class="flex flex-col @sm/sidebar:flex-row">
<!-- ... -->
</nav>
</div>Mapping Back to the Plain-CSS Patterns
| Tailwind | Plain CSS | Section on this page |
|---|---|---|
@container | container-type: inline-size; | "Setting Up a Container" |
@sm:, @md:, ... | @container (min-width: ...) { } | "Querying the Container" |
@container/sidebar + @sm/sidebar: | container: sidebar / inline-size; + @container sidebar (min-width: ...) | "Named Containers" |
Container Query Units
Container query units are relative to the dimensions of the query container. These are useful for fluid sizing within a component.
cqw— 1% of the container's widthcqh— 1% of the container's heightcqi— 1% of the container's inline sizecqb— 1% of the container's block sizecqmin— the smaller ofcqiorcqbcqmax— the larger ofcqiorcqb
For the clamp() patterns that combine these units into fluid font sizing — additive (rem + cqi) vs. pure (cqi only) — see Fluid Font Sizing with clamp().
.card-container {
container-type: inline-size;
}
.card__title {
/* 5% of the container's inline size, clamped */
font-size: clamp(1rem, 5cqi, 2rem);
}
.card__body {
/* Padding relative to container width */
padding: 2cqi;
}Container Query Units as a Token Scale
A one-off clamp() handles a single element. When an entire component subtree — a stage, an embedded widget, a card system — must scale as one unit, define the clamp() values once as custom properties and consume them throughout the subtree. The result is a type and spacing scale proportional to the component's own width instead of the viewport:
.stage {
container-type: inline-size;
container-name: stage;
/* Type scale — proportional to the stage, not the viewport */
--font-size-stage-sm: clamp(0.8rem, 0.7rem + 0.9cqi, 1rem);
--font-size-stage-md: clamp(0.95rem, 0.8rem + 1.4cqi, 1.3rem);
--font-size-stage-lg: clamp(1.1rem, 0.9rem + 1.8cqi, 1.6rem);
/* Spacing scale */
--space-stage-sm: clamp(0.25rem, 0.2rem + 0.5cqi, 0.5rem);
--space-stage-md: clamp(0.5rem, 0.4rem + 1cqi, 1rem);
--space-stage-lg: clamp(1rem, 0.8rem + 2cqi, 2rem);
}
.stage__title {
font-size: var(--font-size-stage-lg);
margin-block-end: var(--space-stage-sm);
}A fixed-aspect stage is the typical host for this pattern — see Letterbox Stage for the container setup itself. Derive each clamp() slope with the same math as viewport-based fluid type — see Fluid Font Sizing with clamp(). Keep the scale to a handful of steps: the discipline arguments in Tight Token Strategy apply unchanged to component-scoped tokens.
Three rules keep such a scale from failing.
Keep the rem Term Dominant
Browser text scaling — text-only zoom, or a larger default font size — scales rem values; it does not scale cqi values: enlarging text does not make the container wider, so the cqi term resolves to the same length while the rem term grows. In a token where the cqi term dominates, scaling text barely changes the rendered size. That fails WCAG 1.4.4 (Resize Text), which requires text to stay functional scaled up to 200%.
Rule of thumb: at the width the component is designed for, the rem term contributes at least ~60% of the resolved preferred value. One deliberate exception: fixed-composition stages (slides, deck viewers) want pure-cqi proportional type instead — see Stage-Proportional Typography. Verify by resolving the preferred expression at the design width:
/* Design width: stage = 480px */
/* OK: rem-dominant */
--font-size-stage-lg: clamp(1.1rem, 0.9rem + 1.8cqi, 1.6rem);
/* rem term: 0.9rem = 14.4px */
/* cqi term: 1.8% of 480px = 8.64px */
/* rem share: 14.4 / 23.04 = 62.5% */
/* NG: cqi-dominant */
--font-size-stage-lg: clamp(1.1rem, 0.25rem + 4cqi, 2rem);
/* rem term: 0.25rem = 4px */
/* cqi term: 4% of 480px = 19.2px */
/* rem share: 4 / 23.2 = ~17% */The demo simulates 200% text zoom by doubling the root font size — the same mechanism browser text scaling uses. The stage width is fixed at 280px, so the cqi terms resolve identically before and after the toggle. Both headings start at almost the same size; only the rem-dominant one responds.
Scope cq Tokens to Their Container's Subtree
A custom property stores the cqi expression, not a resolved length. The unit resolves where the token is consumed, against that element's nearest ancestor container — or against the small viewport size when no ancestor container exists. A stage-scoped spacing token consumed in chrome outside the stage — a toolbar, an overlay, a status bar — therefore resolves against something other than the stage. At the width the page was developed at, the two resolutions can coincide; at extreme widths the outside consumer drifts into absurd gaps.
The rule: a cq-unit token is scoped to the subtree of the container it was designed against. Chrome outside that subtree uses fixed or rem tokens. Suspect patterns:
A stage-scoped variable (
--space-stage-*,--font-size-stage-*) referenced in a toolbar, overlay, tooltip, or any element rendered outside the stage subtreeA cq-unit token consumed on the container element itself — a container cannot query its own size, so the unit resolves against the next container up
Spacing that is correct on one page and wildly different on a wider one — the classic symptom of a token resolving against the viewport fallback
In the demo, one --space-md: 4cqi token feeds both a toolbar outside the stage and a row inside it. The stage is fixed at 280px, so the inside gap stays at ~11px in every viewport. The toolbar has no ancestor container, so its gap resolves against the iframe viewport: at the Mobile viewport the two happen to match; at the Full viewport the toolbar gap explodes.
Keep Hairlines Fixed-px
A 1px hairline is already at the floor of what a screen renders. Give it a cqi width and it goes sub-pixel as the container narrows: the browser paints the fractional line as a faint anti-aliased smear or drops it entirely, depending on rounding and display density. Keep 1–2px hairlines — borders, rules, connectors — as literal px even in an otherwise fully fluid scale.
/* NG: goes sub-pixel below a 400px stage and fades out */
.stage__divider {
border-block-start: 0.25cqi solid hsl(215, 30%, 75%);
}
/* OK: fluid space, fixed hairline */
.stage__card {
padding: var(--space-stage-md);
border: 1px solid hsl(215, 30%, 75%);
}Drag the stage's resize handle (bottom-right corner) narrower and compare: the 0.25cqi border and rule fade toward invisible while the 1px versions stay crisp.
The Three Rules at a Glance
| Rule | Failure it prevents |
|---|---|
rem term at ~60% or more of the resolved value at the design width | Text that ignores browser text scaling (WCAG 1.4.4) |
| cq tokens consumed only inside their container's subtree | Absurd spacing in chrome at extreme widths |
Hairlines stay literal px | Borders and rules fading out in narrow containers |
Card Component Adapting to Container Width
A common real-world use case is a card component that works in any layout context: a narrow sidebar, a medium-width grid column, or a full-width main area.
Container Queries vs. Media Queries
The key difference: media queries respond to the viewport, while container queries respond to the parent container. This demo places the same component in two different-width containers on the same page. The media query version looks identical in both because the viewport hasn't changed. The container query version adapts to each container independently.
In the demo above, the @container cards adapt independently: the card in the narrow container stacks vertically while the card in the wide container goes horizontal. The @media cards both go horizontal because the viewport (the iframe) is wider than 300px — neither card knows how wide its actual container is.
Code Examples
Responsive Card Component
.card-container {
container-type: inline-size;
container-name: card;
}
/* Base: stacked layout */
.card {
display: flex;
flex-direction: column;
}
.card__image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* When container is wide enough: horizontal layout */
@container card (min-width: 500px) {
.card {
flex-direction: row;
}
.card__image {
width: 200px;
aspect-ratio: 1;
}
}
/* When container is very wide: add extra spacing */
@container card (min-width: 800px) {
.card {
gap: 2rem;
padding: 2rem;
}
.card__image {
width: 300px;
}
}Navigation That Adapts to Its Container
.nav-wrapper {
container-type: inline-size;
container-name: nav;
}
.nav-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0;
}
/* Horizontal layout when container allows */
@container nav (min-width: 600px) {
.nav-list {
flex-direction: row;
gap: 1rem;
}
}Combining Container Queries with Container Query Units
.widget-wrapper {
container: widget / inline-size;
}
.widget__title {
font-size: clamp(1rem, 5cqi, 2rem);
}
.widget__body {
padding: clamp(0.5rem, 3cqi, 1.5rem);
}
@container widget (min-width: 400px) {
.widget {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
}
}Common AI Mistakes
Using media queries for component layouts: AI agents default to
@mediaqueries even when the component needs to adapt to its container, not the viewport.Forgetting
container-type: Writing@containerrules without settingcontainer-typeon the parent element. The container must be explicitly declared.Using
container-type: sizeunnecessarily: Height-based containment (size) can cause layout issues. Useinline-sizefor the vast majority of cases.Not naming nested containers: When containers are nested, omitting
container-nameleads to ambiguity —@containerqueries match the nearest ancestor container. Name containers when nesting to target specific ancestors.Querying the element itself: The
@containerquery targets the nearest ancestor withcontainer-typeset, not the element you are styling. The container and the styled element must be different elements.Letting the
cqiterm dominate a fluid token: Browser text scaling growsrem, notcqi, so a cqi-dominantclamp()barely responds when the user enlarges text. Keep theremterm at ~60% or more of the resolved value at the design width.Consuming cq-unit tokens outside their container's subtree: The unit resolves at the consuming element, against its own nearest ancestor container — or the small viewport fallback — not against the container the token was designed for.
Fluid
cqiborder widths: Hairlines go sub-pixel and fade out in narrow containers. Keep 1–2px hairlines as literalpx.
When to Use
The Core Signal
The deciding question is not "is this component small." It is whether the right size or layout for this element is a function of its own box, or a function of the viewport. If two instances of the same component can need different layouts at the exact same viewport width, a media query cannot express that — a media query only ever knows the viewport. A container query, scoped to the component's own wrapper, can.
Worked Example: A Lead Component Next to an Optional Sidebar
A homepage "lead" component (thumbnail, title, blurb) is a common case. Its ideal thumbnail width does not depend on the viewport directly — it depends on whether a sidebar shares the page with it. At the exact same viewport width, the lead's wrapper can be nearly the full page width on one template and noticeably narrower on another, once a sidebar column takes some of that space. A viewport media query cannot see this difference; it only ever sees the one viewport width both templates share. A container query on the lead's own wrapper sees the actual, different result.
In the demo below, both homepage layouts render at the same viewport. Layout A has no sidebar; layout B has one. Compare the two lead components: same viewport, different container widths, different layouts — driven entirely by @container, not by any media query on the lead component itself.
Layout A's lead sits alone in the page shell, so its container is close to the full available width — wide enough to trigger the horizontal layout. Layout B's lead shares the shell with a sidebar; its container is narrower even though the page's viewport has not changed at all, so it stays in the compact, stacked layout. Neither lead component queries the viewport — each only reacts to its own wrapper.
Decision Guide
Reach for
@containerwhen the component's own box determines its layout: cards, nav bars, form groups, and any "designed once, placed everywhere" component where the surrounding page structure (sidebars, modals, grid columns) is outside the component's control.Reach for
@containerwhen the same component needs different layouts at the same viewport width: the lead/sidebar case above. A media query cannot see this difference; a container query, scoped to the component's own wrapper, can.Reach for
@containerfor design system components: anything shipped for reuse across products or pages, where you cannot assume what will surround it.Keep
@mediafor page-level layout: macro decisions like switching the whole page between single-column and multi-column, or showing and hiding page chrome, are viewport decisions, not decisions about any one component's box.