Guide

CSS container queries explained

Harbor Commerce’s merchant dashboard reused the same product card in three places: a full-width catalog grid, a 320 px filter sidebar, and a two-column analytics panel. Each card used viewport media queries tuned for mobile and desktop, so cards in the narrow sidebar still rendered horizontal layouts meant for 400 px+ widths — thumbnails clipped, prices wrapped awkwardly, and CLS spiked when merchants resized the panel. The fix was not more global breakpoints but container queries: each card now reads the width of its immediate column and switches between stacked and horizontal layouts at 280 px, independent of whether the browser window is 1,920 px or 768 px wide. CSS container queries let a component’s styles respond to the size of a containing element rather than the viewport. They are the missing piece of component-driven layout systems — reusable cards, nav bars, and data tables that adapt wherever they are placed. This guide covers container-type and container-name, @container syntax, size vs inline-size queries, container query length units (cqw, cqi, and friends), practical card and sidebar patterns, the Harbor Commerce dashboard refactor, a technique decision table, pitfalls, and a production checklist.

Why viewport breakpoints break reusable components

For fifteen years, responsive design meant @media (min-width: …) tied to the browser viewport. That works when page structure is fixed: a header, a main column, a footer. It fails when the same component appears in contexts with different available width:

  • Multi-column dashboards — a 280 px sidebar and a 900 px main area on the same 1,200 px screen. Viewport queries see 1,200 px; the sidebar component does not.
  • CMS-driven layouts — editors drop widgets into arbitrary grid cells. A “featured product” block cannot assume it always spans full width.
  • Embedded previews — iframes, email clients, and in-app webviews have their own viewport but components may be narrower still.
  • Design systems — maintaining Card--narrow and Card--wide prop variants duplicates CSS and invites drift.

Container queries invert the problem: declare once how a component should look at various container widths, and let placement determine which rules apply. The browser evaluates queries when the container’s size changes — including when a user drags a panel divider or a Grid track grows.

Setting up a query container

A container query needs two pieces: an ancestor marked as a query container, and descendant rules inside @container blocks.

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 1rem;
}

.product-grid__cell {
  container-type: inline-size;
  container-name: product-cell;
}

container-type: inline-size tells the browser to track the element’s inline size (width in horizontal writing mode) and make it available to @container rules on descendants. The optional container-name lets you target a specific ancestor when several nested containers exist.

Shorthand: container: product-cell / inline-size; sets both name and type. Use container-type: size only when you need both width and height to trigger queries — it requires explicit dimensions on the container and is easier to get wrong; prefer inline-size for most layout work.

Writing @container rules

@container product-cell (min-width: 280px) {
  .product-card {
    display: grid;
    grid-template-columns: 96px 1fr;
    gap: 0.75rem;
  }
  .product-card__thumb { aspect-ratio: 1; }
}

@container product-cell (max-width: 279px) {
  .product-card {
    display: flex;
    flex-direction: column;
    text-align: center;
  }
  .product-card__thumb { width: 100%; }
}

Rules inside @container apply only when the named (or nearest) container matches the condition. You can use min-width, max-width, min-height, ranges with width > 200px and width < 400px, and the style() query for custom properties (e.g. toggle a theme flag on the container).

Container query length units

Beyond boolean breakpoints, container queries introduce relative units tied to the query container’s dimensions:

  • cqw / cqh — 1% of container width / height
  • cqi / cqb — 1% of inline / block size (writing-mode aware)
  • cqmin / cqmax — smaller or larger of inline and block

Example: font-size: clamp(0.875rem, 2.5cqi + 0.5rem, 1.125rem); scales card titles with column width without hard breakpoints. Pair with clamp() from fluid typography patterns in responsive design guides for smooth scaling between container thresholds.

Important: cq* units resolve against the nearest query container. If no ancestor has container-type, the units fall back to the viewport — often producing sizes ten times too large. Always set container-type on the element you intend as the reference.

Practical patterns

Card that flips layout by column width

The most common pattern: stack image above text in narrow columns, switch to a horizontal row when the container exceeds ~300 px. Set container-type: inline-size on the grid cell or card wrapper, not the card itself, so padding is included in the measured width.

Navigation that collapses inside a sidebar

A vertical nav in a 240 px drawer can show icons only; the same component in a 400 px panel shows icons plus labels. Viewport queries cannot distinguish these when both viewports are 1,440 px desktop windows.

Data tables in resizable panels

Hide non-critical columns with @container (max-width: 500px) and show a compact summary row. Combine with horizontal scroll as a fallback for accessibility rather than truncating primary data without a expand affordance.

Combining with Grid auto-fit

grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)) creates columns of varying count; container queries inside each cell handle the within-cell layout. Viewport media queries still govern page-level padding and column minimums — the two layers complement each other.

Worked example: Harbor Commerce dashboard refactor

Before container queries, Harbor Commerce maintained three React variants of ProductCard: compact, default, and featured. Merchants picking a “compact” widget in a wide column got tiny thumbnails; “featured” in the sidebar overflowed.

  1. Removed variant props; one markup structure for all placements.
  2. Wrapped each card in .product-grid__cell with container-type: inline-size.
  3. Replaced viewport rules inside the card with two @container blocks at 280 px and 420 px thresholds.
  4. Used 2.2cqi for title font size between breakpoints to avoid a third hard switch.
  5. Reserved thumbnail aspect-ratio in both layouts to eliminate CLS on container resize (merchants drag split panes).

Result: zero prop-driven layout branches, one visual regression snapshot suite, and cards that self-correct when the analytics panel is docked left vs bottom. Page-level @media rules now only adjust outer grid gaps and nav visibility — concerns that genuinely depend on viewport width.

Technique decision table

Goal Approach Why
Page padding, global nav, hero layout Viewport @media queries Truly viewport-scoped concerns; well-supported everywhere
Reusable card, widget, or table adapts to column width Container queries (inline-size) Component responds to placement, not window size
Layout depends on both width and height of a fixed box container-type: size + height query Requires explicit container dimensions; use sparingly
Fluid type scaling within a component cqw / cqi with clamp() Smooth scaling between breakpoints without extra rules
Detect when element enters viewport for lazy load Intersection Observer Container queries style by size; they do not detect visibility
Measure dynamic container size in JavaScript ResizeObserver When CSS alone cannot express the layout rule (rare)

Common pitfalls

  • Missing container-type@container rules silently never match; cq* units fall back to the viewport.
  • Setting container on the styled element itself — the element cannot query its own size. Wrap with an extra div if needed.
  • Using size without fixed dimensions — height-based queries need a defined block size; otherwise containment collapses.
  • Replacing all media queries — page chrome still needs viewport breakpoints. Container queries solve component locality, not global layout.
  • Deep nesting of named containers — descendants use the nearest container; ambiguous names cause confusion. Prefer one named container per component subtree.
  • Forgetting containment side effectscontainer-type establishes size containment; positioned descendants may clip. Test overflow and position: absolute children.
  • No fallback for older browsers — baseline stacked layout should work without @container; progressive enhancement avoids broken sidebars in legacy engines.

Production checklist

  • Identify components reused in multiple column widths; candidates for container queries.
  • Add container-type: inline-size on the wrapper, not the styled child.
  • Define a mobile-first default layout that works without @container.
  • Choose container breakpoints from component content (image size, label length), not viewport tokens.
  • Use cq* units sparingly for fluid type and spacing between hard switches.
  • Reserve image dimensions in all container states to prevent CLS on resize.
  • Keep page-level layout in viewport media queries; component detail in container queries.
  • Test by resizing grid columns and split panes, not only by narrowing the browser window.
  • Verify in Firefox, Chrome, and Safari; container queries are baseline in modern engines.
  • Document container names in your design system so nested components do not clash.

Key takeaways

  • Container queries let components respond to parent width, fixing the reusable-card problem viewport breakpoints cannot solve.
  • Set container-type: inline-size on a wrapper; write layout switches in @container blocks on descendants.
  • Use cqw and cqi for fluid scaling; use named containers when multiple query roots nest.
  • Combine with Grid auto-fit for column count and container queries for within-cell layout.
  • Keep viewport media queries for page chrome; container queries for component internals.

Related reading