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--narrowandCard--wideprop 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 / heightcqi/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.
- Removed variant props; one markup structure for all placements.
- Wrapped each card in
.product-grid__cellwithcontainer-type: inline-size. - Replaced viewport rules inside the card with two
@containerblocks at 280 px and 420 px thresholds. - Used
2.2cqifor title font size between breakpoints to avoid a third hard switch. - 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—@containerrules 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
sizewithout 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 effects —
container-typeestablishes size containment; positioned descendants may clip. Test overflow andposition: absolutechildren. - 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-sizeon 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-sizeon a wrapper; write layout switches in@containerblocks on descendants. - Use
cqwandcqifor fluid scaling; use named containers when multiple query roots nest. - Combine with Grid
auto-fitfor column count and container queries for within-cell layout. - Keep viewport media queries for page chrome; container queries for component internals.
Related reading
- Responsive web design explained — mobile-first breakpoints, fluid type, and when viewport queries still apply
- CSS Flexbox and Grid layout explained — one- and two-dimensional layout primitives that pair with container queries
- Core Web Vitals explained — CLS and layout stability when components reflow at container thresholds
- Intersection Observer API explained — visibility detection for lazy loading, not size-based styling