Lesson 09 / 23
Container Queries
Moving the query's subject from the viewport to the component's container, declaring a container and the containment it requires, container units, and the same component making the right decision in two different places.
Contents
Every query in the previous lesson looked at one thing: the viewport itself. For the page’s layout, that is the right subject; it really is the screen’s width that decides how many columns the page has.
It is not the right subject for a measurement card. What determines how the card should look is not the screen’s width, it is the width of the container it is placed inside. This lesson changes the subject of the query and measures with computation how much that change matters.
The Same Card, Two Containers
On the station page, the measurement card appears in two places: in the card grid in the main section, and in the summary list in the aside. The two containers have different widths, and those widths cannot be read directly from the viewport’s width.
The card has its own threshold: below 280 units, the label, value, and unit stack; above it they sit side by side. The program below gives this decision with two separate query types.
// container.mjs — the size the same card gets in two different containers, decided two ways const PADDING = 48; const PAGE_GAP = 24; const MAIN_MIN = 480, ASIDE_MIN = 260; const CARD = 200, CARD_GAP = 16; // page layout: minmax(480px, 2fr) minmax(260px, 1fr), single-column threshold 812px function sections(viewport) { if (viewport < 812) return { main: viewport - PADDING, aside: viewport - PADDING }; let remaining = viewport - PADDING - PAGE_GAP; let main = (remaining * 2) / 3, aside = remaining / 3; if (aside < ASIDE_MIN) { aside = ASIDE_MIN; main = remaining - aside; } if (main < MAIN_MIN) { main = MAIN_MIN; aside = remaining - main; } return { main, aside }; } // card grid: repeat(auto-fill, minmax(200px, 1fr)) function cardSize(containerWidth) { const n = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD + CARD_GAP))); return { columns: n, size: (containerWidth - CARD_GAP * (n - 1)) / n }; } // the card's own threshold: below 280px labels stack, above it they sit side by side const CARD_THRESHOLD = 280; console.log("size at which the card switches to a side-by-side layout: 280px"); console.log("media query decision: (min-width: 812px) -> every card side-by-side"); console.log("container query decision: (min-width: 280px) -> every card by its own container\n"); console.log("viewport main aside main aside media query container query"); console.log("width card card main / aside main / aside"); const mismatches = []; for (const W of [400, 720, 812, 900, 1024, 1280, 1600]) { const { main, aside } = sections(W); const mainCard = cardSize(main), asideCard = cardSize(aside); const media = W >= 812 ? "side-by-side" : "stacked"; const containerMain = mainCard.size >= CARD_THRESHOLD ? "side-by-side" : "stacked"; const containerAside = asideCard.size >= CARD_THRESHOLD ? "side-by-side" : "stacked"; if (media !== containerMain) mismatches.push(`${W}px main section`); if (media !== containerAside) mismatches.push(`${W}px aside`); console.log( `${String(W).padStart(6)} ${main.toFixed(0).padStart(6)} ${aside.toFixed(0).padStart(6)} ` + `${mainCard.size.toFixed(0).padStart(6)} ${asideCard.size.toFixed(0).padStart(6)} ` + `${(media + " / " + media).padEnd(25)} ${containerMain} / ${containerAside}`, ); } console.log(`\nnumber of cases where the media query decides wrong: ${mismatches.length}`); for (const m of mismatches) console.log(` ${m}`);
size at which the card switches to a side-by-side layout: 280px media query decision: (min-width: 812px) -> every card side-by-side container query decision: (min-width: 280px) -> every card by its own container viewport main aside main aside media query container query width card card main / aside main / aside 400 352 352 352 352 stacked / stacked side-by-side / side-by-side 720 672 672 213 213 stacked / stacked stacked / stacked 812 480 260 232 260 side-by-side / side-by-side stacked / stacked 900 552 276 268 276 side-by-side / side-by-side stacked / stacked 1024 635 317 201 317 side-by-side / side-by-side stacked / side-by-side 1280 805 403 258 403 side-by-side / side-by-side stacked / side-by-side 1600 1019 509 243 247 side-by-side / side-by-side stacked / stacked number of cases where the media query decides wrong: 10 400px main section 400px aside 812px main section 812px aside 900px main section 900px aside 1024px main section 1280px main section 1600px main section 1600px aside
Ten of fourteen decisions are wrong. This ratio alone shows that a media query is a poor tool at the component level, but what is actually interesting is the direction of the errors.
The first row gives the opposite of what is expected. The viewport is 400 units — a narrow screen. Since the page is single-column, the card grid produces a single column, and the card is 352 units wide. The card is wide on a narrow screen. The media query, because the screen is narrow, stacks the card anyway.
The last row is the other end of the same misjudgment. The viewport is 1600 units; the main section grows to 1019 units and the card grid produces four columns. Each card is 243 units: below the card’s own threshold. The card is narrow on a wide screen.
The rows in between show a third case: at 1024 and 1280 units, the cards in the main section are narrow while the card in the aside is wide. A single viewport size contains two containers that need two different answers at the same time. A media query can only give one answer, and at least one of the two ends up wrong.
The result is a rule: the page’s structure is asked of the viewport, a component’s shape is asked of its container.
Declaring a Container
A container query consists of two steps. First an element is declared a query container, then elements descending from that container query the container’s size.
The container-type declaration declares the container and takes three values. inline-size
makes only the container’s size on the inline axis queryable. size opens both axes. normal
opens no size query; it allows only style queries.
The declaration has a cost, and that cost is a logical necessity. If the container’s size is
going to be queried, that size must not be affected by the query’s result; otherwise a loop
forms: the container narrows, the query changes, the content changes, the container widens, the
query changes again. To prevent this, containment is applied to the container:
inline-size makes the container’s size independent of its content on the inline axis, size
does so on both axes.
The practical result is this: a container given container-type: size must be given height
from outside, or its height is treated as zero. inline-size does not create this problem,
because inline size usually already comes from the container’s own container. This is why
inline-size is the common value.
The second declaration, container-name, gives the container a name. A nameless query targets
the nearest query container; a named query looks for the nearest container carrying that
name. When containers are nested, the name removes the ambiguity of which one is being queried.
The container shorthand writes both together.
A boundary appears here, and it is surprising when forgotten: an element cannot query itself. Declarations written on the query container cannot change based on the container’s own size; only its descendants can. This is why a component’s outermost element is declared the container, and the elements being styled sit inside it.
Container Units
When the query’s subject changes, length units need a counterpart too. Container query
units correspond to one percent of the container’s size: cqi on the inline axis, cqb on
the block axis, cqw and cqh on the physical axes, cqmin and cqmax the smaller and larger
of the two.
// cq-unit.mjs — comparing the container unit (cqi) against the viewport unit (vw) const PADDING = 48, PAGE_GAP = 24; const MAIN_MIN = 480, ASIDE_MIN = 260; const CARD = 200, CARD_GAP = 16, ROOT = 16; function sections(viewport) { if (viewport < 812) return { main: viewport - PADDING, aside: viewport - PADDING }; let remaining = viewport - PADDING - PAGE_GAP; let main = (remaining * 2) / 3, aside = remaining / 3; if (aside < ASIDE_MIN) { aside = ASIDE_MIN; main = remaining - aside; } return { main, aside }; } function cardSize(container) { const n = Math.max(1, Math.floor((container + CARD_GAP) / (CARD + CARD_GAP))); return (container - CARD_GAP * (n - 1)) / n; } const clamp = (lo, target, hi) => Math.max(lo, Math.min(target, hi)); const MIN = 0.875 * ROOT; // 14px const MAX = 1.25 * ROOT; // 20px const BASE = 0.75 * ROOT; // 12px // cqi: one percent of the container's inline size const cqiValue = (container) => clamp(MIN, BASE + (1.5 * container) / 100, MAX); // vw: one percent of the viewport const vwValue = (viewport) => clamp(MIN, BASE + (1.5 * viewport) / 100, MAX); console.log("font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem) (root 16px)"); console.log("comparison: if the same expression were written with 1.5vw\n"); console.log("viewport main card aside card cqi: main cqi: aside vw: main vw: aside does vw distinguish"); for (const W of [400, 720, 900, 1024, 1280, 1600]) { const { main, aside } = sections(W); const mainCard = cardSize(main), asideCard = cardSize(aside); const cqiMain = cqiValue(mainCard), cqiAside = cqiValue(asideCard); const vwMain = vwValue(W), vwAside = vwValue(W); console.log( `${String(W).padStart(8)} ${mainCard.toFixed(0).padStart(9)} ${asideCard.toFixed(0).padStart(10)} ` + `${cqiMain.toFixed(2).padStart(11)} ${cqiAside.toFixed(2).padStart(11)} ` + `${vwMain.toFixed(2).padStart(8)} ${vwAside.toFixed(2).padStart(9)} ${(vwMain === vwAside ? "no" : "yes").padStart(18)}`, ); } console.log("\n--- the same card alone: the cqi value as the container's size changes ---"); console.log("container 1cqi applied font size which zone"); for (const container of [180, 240, 300, 380, 460, 560]) { const oneCqi = container / 100; const v = cqiValue(container); const zone = v === MIN ? "lower bound" : v === MAX ? "upper bound" : "preferred"; console.log( `${String(container).padStart(9)} ${oneCqi.toFixed(2).padStart(4)} ${v.toFixed(2).padStart(19)} ${zone.padStart(13)}`, ); }
font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem) (root 16px)
comparison: if the same expression were written with 1.5vw
viewport main card aside card cqi: main cqi: aside vw: main vw: aside does vw distinguish
400 352 352 17.28 17.28 18.00 18.00 no
720 213 213 15.20 15.20 20.00 20.00 no
900 268 276 16.02 16.14 20.00 20.00 no
1024 201 317 15.01 16.76 20.00 20.00 no
1280 258 403 15.87 18.04 20.00 20.00 no
1600 243 247 15.64 15.70 20.00 20.00 no
--- the same card alone: the cqi value as the container's size changes ---
container 1cqi applied font size which zone
180 1.80 14.70 preferred
240 2.40 15.60 preferred
300 3.00 16.50 preferred
380 3.80 17.70 preferred
460 4.60 18.90 preferred
560 5.60 20.00 upper bound
The last column shows why the viewport unit falls short for component scaling: vw never
distinguishes the two containers on any row. On the same page, at the same time, it gives the
same value to two containers of different widths — and past 720 units it is already pinned to
the upper bound, so the value never changes at all after that.
The container unit, on the other hand, tracks the container’s own size. At a 1024-unit viewport, the card in the main section gets 15.01 units of text, the card in the aside gets 16.76; both are proportional to their own width.
The second table isolates a single container’s behavior. As the container widens, 1cqi grows
and the preferred value rises; at 560 units it hits the upper bound and gets clamped. The
three-zone behavior from the CSS Functions lesson applies here too, only the input has changed.
One detail: if container units are used on an element that is not inside any query container,
they resolve against the viewport. Writing the unit does not declare the container;
container-type must be written separately.
The Component Carries Its Own Threshold
Container queries’ real gain is not in the computation, it is in the stylesheet’s structure.
Written with a media query, a component’s threshold has to be computed separately for every place the component appears: one threshold for the card in the main section, another for the card in the aside, both at once when the page layout changes. When the component moves to a new place, the thresholds have to be recomputed.
Written with a container query, the threshold belongs to the component itself and lives in one place. Wherever the component is placed, it makes the right decision; when the page layout changes, the component’s style code does not change.
/* station.css — step 9: the card queries its own container */ .measurement-card-container { container-type: inline-size; container-name: card; } .measurement-card { display: grid; gap: 0.25rem; font-size: clamp(0.875rem, 0.75rem + 1.5cqi, 1.25rem); } @container card (min-width: 280px) { .measurement-card { grid-template-columns: 1fr auto; align-items: baseline; } .measurement-card .description { grid-column: 1 / -1; } } @container card (min-width: 420px) { .measurement-card .trend { display: block; } }
The container being a separate element is a necessity: the card cannot query itself. The
container carries only container-type and container-name; every visual declaration is on
the .measurement-card element inside it.
The two thresholds correspond to two separate questions. At 280 units, the label and value move side by side; at 420 units, the trend indicator becomes visible. Both are read not from the viewport but from the container’s width, and give two different results in two different containers.
There is a third form of the query as well. Instead of a size, the value of a custom property
can be queried; the @container style(--state: warning) notation branches style based on a
property’s value defined on the container. Its difference from a size query is that it looks
not at the container’s size but at its style state, and it requires no containment.
Summary
- The page’s structure is asked of the viewport, a component’s shape of its container; the two questions cannot be answered with the same measure.
- In the station page’s card layout, a media query gives the wrong result in ten of fourteen decisions; a card can be wide on a narrow screen and narrow on a wide screen.
container-typemakes an element a query container and applies containment in exchange; sincesizerequires height to be given from outside,inline-sizeis less restrictive.- An element cannot query itself; the container is declared as a separate element outside the component being styled.
- Container units correspond to one percent of the container’s size and distinguish two containers on the same page; the viewport unit cannot.
- When the threshold is written on the component itself, the style code does not change when the component moves; a threshold written with a media query has to be recomputed for every placement.
Next Step
In this lesson, font size was tied to a container’s width once and placed inside a clamp()
expression. The expression’s three numbers — the lower bound, the preferred value, the upper
bound — looked chosen, but how they were chosen was never said. The next lesson makes these
three numbers computable: given two anchor points, how is the linear transition between them
built, why does the ratio between scale steps narrow on a narrow screen, and what does it mean
for font size to respect the user’s own setting?
To keep your progress and take notes, Log in
My notes
Log in to take notes.