zudo-css-wisdom
GitHub repository

Type to search...

to open search from anywhere

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.

Basic Container Query — card layout adapts to 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;
}
Named Containers — same component adapts differently in sidebar vs main

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:

VariantMinimum container width
@3xs16rem (256px)
@2xs18rem (288px)
@xs20rem (320px)
@sm24rem (384px)
@md28rem (448px)
@lg32rem (512px)
@xl36rem (576px)
@2xl42rem (672px)
@3xl48rem (768px)
@4xl56rem (896px)
@5xl64rem (1024px)
@6xl72rem (1152px)
@7xl80rem (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 the sm: / 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

TailwindPlain CSSSection on this page
@containercontainer-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 width

  • cqh — 1% of the container's height

  • cqi — 1% of the container's inline size

  • cqb — 1% of the container's block size

  • cqmin — the smaller of cqi or cqb

  • cqmax — the larger of cqi or cqb

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 — text and spacing scale with container

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.

rem-dominant vs cqi-dominant tokens under simulated 200% text zoom

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 subtree

  • A 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.

One cqi token, consumed inside vs outside its container — switch viewports

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.

Fluid cqi hairline vs fixed 1px hairline — drag the stage narrower

The Three Rules at a Glance

RuleFailure it prevents
rem term at ~60% or more of the resolved value at the design widthText that ignores browser text scaling (WCAG 1.4.4)
cq tokens consumed only inside their container's subtreeAbsurd spacing in chrome at extreme widths
Hairlines stay literal pxBorders 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.

Adaptive Card Grid — cards respond to their container, not the viewport

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.

Container queries vs media queries — same component, different containers

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 @media queries even when the component needs to adapt to its container, not the viewport.

  • Forgetting container-type: Writing @container rules without setting container-type on the parent element. The container must be explicitly declared.

  • Using container-type: size unnecessarily: Height-based containment (size) can cause layout issues. Use inline-size for the vast majority of cases.

  • Not naming nested containers: When containers are nested, omitting container-name leads to ambiguity — @container queries match the nearest ancestor container. Name containers when nesting to target specific ancestors.

  • Querying the element itself: The @container query targets the nearest ancestor with container-type set, not the element you are styling. The container and the styled element must be different elements.

  • Letting the cqi term dominate a fluid token: Browser text scaling grows rem, not cqi, so a cqi-dominant clamp() barely responds when the user enlarges text. Keep the rem term 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 cqi border widths: Hairlines go sub-pixel and fade out in narrow containers. Keep 1–2px hairlines as literal px.

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.

Lead component: same viewport, two different container widths

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 @container when 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 @container when 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 @container for design system components: anything shipped for reuse across products or pages, where you cannot assume what will surround it.

  • Keep @media for 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.

References

Revision History

CreatedUpdated