---
title: 'Container Queries'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/container-queries'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:28+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```css
/* 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-type` makes an element a query container and applies containment in exchange;
  since `size` requires height to be given from outside, `inline-size` is 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?
