Suppose you have a product card. On the home page it sits in a three-column grid, about 300px wide. On the product page it appears in a 700px-wide main column. In a sidebar it’s 240px. With media queries alone, the card can only ask “how wide is the viewport?”, and the answer is the same 1280px in all three places. So you end up writing .sidebar .card, .main .card and .grid .card overrides, and every new placement needs another one.
Container queries let the card ask the question it actually cares about: “how much room have I got?” The component carries its own responsive rules, and it behaves correctly wherever you put it.
Declaring a container
Nothing is a container by default. You opt in on an ancestor:
.card-slot {
container-type: inline-size;
}inline-size means “you may query my inline size” (width, in a horizontal writing mode). The other useful value is size, which allows queries on both axes, and normal, the default, which makes the element a container only for style queries.
Why opt in at all? Because a container query creates a potential loop. If a child’s styles depend on the container’s width, and the container’s width depends on its children (as it does for a shrink-to-fit element or a flex item with flex-basis: auto), the browser could flip between two layouts forever. container-type: inline-size breaks the loop by applying size containment on the inline axis: the container’s width is computed as if it had no content. That’s fine for block-level elements, whose width comes from their parent anyway. It’s a problem for elements whose width comes from their content, which is the first gotcha below.
The card, worked through
<div class="card-slot">
<article class="card">
<img src="/img/shoe.jpg" alt="Trail Runner 3, side view" width="600" height="400">
<div class="card-body">
<h3>Trail Runner 3</h3>
<p>Lightweight trail shoe with a rock plate.</p>
<p class="price">USD 129</p>
</div>
</article>
</div>.card-slot {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
gap: 0.75rem;
}
.card img {
width: 100%;
height: auto;
}
@container card (min-width: 420px) {
.card {
grid-template-columns: 40% 1fr;
align-items: start;
}
}
@container card (min-width: 640px) {
.card h3 {
font-size: 1.5rem;
}
}Walk through the three placements:
- Sidebar, slot 240px wide. Neither query matches. The card is a single column: image on top, text below.
- Home grid, slot 300px wide. Still under 420px, so still stacked. Same as the sidebar.
- Product page, slot 700px wide. Both queries match. The image takes 40% of the width (280px) on the left, the text sits to its right, and the heading grows to 1.5rem.
Resize the browser and the card in the grid switches layout when its column crosses 420px, which might happen at a viewport of 1350px or 900px depending on how the grid is set up. The card doesn’t know or care.
Notice that the rules style .card and its children, never .card-slot. That’s the second rule people trip on: an element can’t query itself. The query is evaluated against the nearest ancestor that is a container, so the container is a wrapper and the styles land on its descendants. If you write container-type: inline-size on .card and then @container (min-width: 420px) { .card { … } }, the rule looks for a container above .card, finds none (or finds some unrelated one), and doesn’t do what you meant.
Gotchas you’ll hit in practice
The container collapses to zero width. Put container-type: inline-size on an element whose width comes from its content (an inline-block, a float, an absolutely positioned box, a flex item with flex: 0 0 auto, a grid item in an auto track) and it shrinks to zero, because size containment says “pretend you have no content” and then there’s nothing to size from. The card’s image and text overflow out of a zero-width box. Fix it by giving the container a definite width from outside (width: 100%, a 1fr track, flex: 1).
container-type: size collapses the height. Same reason on the block axis: with no content considered, the height is zero unless you set one. Use size only when the height is set explicitly, such as a fixed-height panel. Most components want inline-size.
Unnamed queries match the nearest container. If cards sit inside a layout region that’s also a container, @container (min-width: 420px) from inside the card resolves against whichever container is closest. Naming them (container-name: card and @container card (…)) removes the ambiguity. The shorthand container: card / inline-size sets both.
Queries use the content box. A 420px container with 20px of padding on each side has a 380px content box, and that’s what min-width: 420px is compared against. Remember this when the breakpoint “should” match and doesn’t.
Container units
Container queries came with units relative to the query container’s size. cqi is 1% of the container’s inline size, cqb is 1% of its block size, and cqmin/cqmax are the smaller and larger of the two. They let typography scale with the component:
.card h3 {
font-size: clamp(1rem, 0.75rem + 3cqi, 1.75rem);
}In the 240px sidebar slot, 3cqi is 7.2px, so the size is 12px + 7.2px = 19.2px (assuming a 16px root font size), comfortably above the 16px floor. In the 700px slot, 3cqi is 21px, giving 33px, which clamp caps at 28px. The rem part keeps text responsive to the user’s font-size setting, which pure cqi values wouldn’t.
If no ancestor is a container, cqi falls back to the small viewport units (svi), so it never breaks, it just behaves like a viewport unit.
Style queries, briefly
@container style(--variant: compact) { … } lets you query a custom property’s computed value on the container. Chromium browsers and Safari shipped style queries for custom properties first; check current Firefox support before relying on them. They’re handy for theme variants passed down from a parent, but size queries are what interviews are usually about.
Where media queries still belong
Container queries don’t replace media queries. Page-level layout (how many columns the main grid has, whether the sidebar is shown) depends on the viewport, so it stays in @media. User preferences (prefers-reduced-motion, prefers-color-scheme) and device capabilities (hover, pointer) are only available as media features. A reasonable split: media queries for the page skeleton and user preferences, container queries for anything inside a reusable component.
Interview practice
A colleague adds container-type: inline-size to a navigation bar’s <ul>, which is a flex item with flex: none inside the header. The menu items vanish and overflow to the left. Then they move it to the header and write @container (min-width: 600px) { header { flex-direction: row; } }, which never applies. Explain both problems.
Answer
The first is size containment on an element sized by its content. A flex item with flex: none takes its width from its content, but container-type: inline-size tells the browser to compute that inline size as if there were no content, so the <ul> becomes zero wide and its items overflow. Give it a width that doesn’t depend on its children, for example flex: 1 so it takes the remaining space, or put the container on a wrapper that already has a definite width.
The second is that an element can’t query itself. The @container rule looks for a container among the header’s ancestors, not the header itself. Either style the header’s children inside that query (@container (min-width: 600px) { header > nav { … } }) or move the container declaration up to a wrapper around the header. Naming the container would also have made the mismatch obvious, because @container site-header (…) only resolves against an element with that name.
Keep going
- HTML and CSS topic hub
- HTML and CSS multiple-choice questions
- CSS responsive units and content constraints
- Flexbox vs Grid: choosing a layout
- MDN: container queries