Building a Container-Query-Aware Spacing System

Part of Spacing & Layout Tokens. A card in a narrow sidebar and the same card in a wide main column need different internal spacing, and the viewport cannot tell them apart. Container queries can.

The same component in two containers at one viewport width One wide viewport containing a narrow sidebar and a wide main column, with the same card component in each, showing that viewport-based spacing gives both the same padding while container-based spacing differentiates them. viewport: 1440px — one value for every component on the page sidebar · 260px card wants 12px inset main column · 880px the same card wants 24px inset — the viewport cannot express this difference
Both cards see the same viewport. Only the container knows that one of them has a quarter of the space available.

Prerequisites Link to this section

  • A spacing scale with role-based names, as covered in naming conventions for responsive spacing scales.
  • Components whose outer element can carry container-type without breaking their layout.
  • An understanding that establishing a container changes layout containment, which has consequences described below.
  • A test setup able to render a component at several container widths independently of the viewport.

Step-by-Step Implementation Link to this section

Step 1 — Establish the container on the component wrapper Link to this section

Intent: give the component a container to query, without changing what it looks like.

.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

Why this works: inline-size containment tracks the inline dimension only, which is what spacing decisions depend on and which avoids the layout surprises that size containment brings — the latter requires the element’s block size to be independent of its contents, which is rarely true. Naming the container matters once components nest: an unnamed query matches the nearest container of any name, which produces the wrong answer inside a card inside a panel.

Step 2 — Express spacing tokens in container units Link to this section

Intent: let the value scale with the component rather than the window.

.card-wrapper {
  /* cqi is 1% of the container's inline size. */
  --card-inset: clamp(0.75rem, 0.5rem + 1.5cqi, 1.5rem);
}

.card {
  padding: var(--card-inset);
}

Why this works: cqi resolves against the nearest applicable container rather than the viewport, so the identical declaration produces 12 pixels in the sidebar and 24 in the main column. Keeping the rem term in the expression preserves zoom awareness, exactly as with viewport-based fluid spacing.

Step 3 — Add a fallback for the no-container case Link to this section

Intent: a container unit with no container resolves against the viewport, silently and usually wrongly.

.card {
  /* Fallback for engines without container query support, and for the case
     where an ancestor container was never established. */
  padding: var(--ds-space-inset-md);
}

@supports (container-type: inline-size) {
  .card { padding: var(--card-inset); }
}

Why this works: the feature query means an unsupported engine gets the fixed step rather than a container unit it cannot resolve. The more insidious case — supported engine, missing container — is not caught by @supports at all, which is why step 6 adds a runtime assertion for it.

Step 4 — Switch discrete steps with a container query Link to this section

Intent: some spacing changes are step changes rather than continuous ones, and a query expresses that better than a clamp.

@container card (min-width: 420px) {
  .card { --card-inset: var(--ds-space-inset-lg); }
  .card__header { --card-gap: var(--ds-space-stack-md); }
}

@container card (min-width: 720px) {
  .card { --card-inset: var(--ds-space-inset-xl); }
}

Why this works: the query sets tokens rather than properties, so the component’s rules stay in one place and the query block only adjusts values. It also keeps the change on the 4-pixel grid, which a continuous cqi expression cannot guarantee — a real trade-off between smooth scaling and grid conformance, and the reason most systems use queries for component-internal spacing and clamps for page-level rhythm.

Continuous container units against discrete container queries A comparison of clamp with cqi producing continuously varying values off the grid, against container queries producing discrete steps that stay on the spacing scale. clamp with cqi smooth at every container width one declaration, no breakpoints but: 17.3px is not on the grid and the value is unpredictable best for: page rhythm container query steps values stay on the scale predictable and reviewable but: a visible step at the threshold and breakpoints to maintain best for: component internals
The choice is between grid conformance and smoothness. Component internals almost always want the grid; section rhythm almost always wants smoothness.

Step 5 — Understand what containment costs Link to this section

Intent: container-type is not free, and knowing what it changes prevents surprising layout bugs.

Establishing an inline-size container applies layout, style and inline-size containment to the element. Three consequences follow. The element’s intrinsic width no longer depends on its contents, so a container that was sized by a long unbreakable string will now size differently. Absolutely positioned descendants resolve against the container rather than a further ancestor. And the element becomes a new containing block for fixed-position descendants, which can move a modal that was positioned relative to the viewport.

None of these is a reason to avoid containers; all of them are reasons to establish the container on a wrapper element that exists for that purpose, rather than on a component’s outermost styled element.

Step 6 — Assert the container exists Link to this section

Intent: catch the silent failure where a container unit resolves against the viewport.

// tests/container-spacing.spec.mjs
import { test, expect } from '@playwright/test';

test('card spacing responds to its container, not the viewport', async ({ page }) => {
  await page.setViewportSize({ width: 1440, height: 900 });
  await page.goto('/gallery/card-in-two-columns/');

  const read = (sel) => page.locator(sel).evaluate(
    (el) => getComputedStyle(el).paddingLeft);

  const narrow = await read('.sidebar .card');
  const wide = await read('.main .card');

  // Same viewport, same component, different containers: values must differ.
  expect(narrow).not.toBe(wide);
});

Why this works: this single assertion catches every version of the missing-container failure, because when no container is established both instances resolve against the same viewport and produce identical padding. It needs no knowledge of the expected values, which keeps it valid as the scale is tuned.

Verification Link to this section

Beyond the assertion above, check the three cases that container queries make possible and that nothing else tests. Render the component at its narrowest supported container width and confirm the spacing has not collapsed below the scale’s floor. Render it inside another container-establishing component and confirm the named query still matches the intended ancestor. And resize the container without resizing the viewport — a resizable panel, a sidebar drag handle — and confirm the spacing updates live.

The last case is where container queries earn their keep most visibly, and it is the one a viewport-based system cannot do at all: a panel a user drags wider re-spaces its contents immediately, with no JavaScript and no breakpoint at the window level.

Resolution order for a container unit A resolution chain showing a container unit looking for the nearest named container, then the nearest container of any type, then falling back to the viewport when none exists. 1 · nearest ancestor with a matching container-name — the intended answer 2 · nearest ancestor with any container-type — right by accident, wrong when nested 3 · no container at all — resolves against the viewport, silently
The third row produces no error and no warning — the component simply behaves as though it were viewport-scaled, which is why it needs an explicit test.

Troubleshooting Link to this section

Symptom Likely cause Fix
Spacing is identical in both containers No container was established on either wrapper Add container-type: inline-size; assert with the differ test
A nested component queries the wrong ancestor The query is unnamed and matched the nearest container Name every container and query by name
A modal is positioned inside the card The container is now the containing block for fixed descendants Establish the container on a wrapper that does not contain positioned overlays
The container collapses to zero width container-type: size was used where inline-size was needed Use inline-size unless the block dimension is genuinely independent
Padding is off the 4px grid A continuous cqi expression is in use Switch to discrete container queries for component internals

Migration Note Link to this section

Container-aware spacing can be adopted one component at a time, which is unusual for a layout change. Pick the component that most visibly suffers from being placed in two widths — a card or a media object, usually — and convert it alone. Everything else continues using the fixed scale, and the two coexist without interacting.

Establish the container on a new wrapper element rather than on the component’s existing root. That extra element is a small structural cost and it isolates the containment consequences from the component’s own layout, which makes the change reviewable and reversible. Once several components have been converted, the wrapper pattern can be extracted into a shared layout primitive.

Do not convert page-level rhythm to container units. The page’s own width is the viewport, so a container unit there resolves to the same thing a viewport unit would, with more indirection and an extra failure mode.

When the Viewport Is Still the Right Question Link to this section

Container queries are not a replacement for media queries, and treating them as one produces a system that is harder to reason about for no benefit. Three categories of decision remain viewport-level.

Page structure is one. Whether a layout is one column or three is a decision about the device and the window, not about any component’s width — indeed it is the decision that determines the containers everything else queries. Expressing it as a container query on the body is indirection with no payoff.

Input modality is another. Touch targets, hover affordances and pointer precision follow from the device, and the relevant queries are pointer and hover rather than any width at all. A component in a narrow container on a desktop still has a mouse.

Reading measure is the third and least obvious. The comfortable line length for body text is a property of the text column, which usually is a container — but the page’s maximum content width, the thing that establishes that column in the first place, is a viewport decision.

The practical division that works: the viewport decides the layout, the layout establishes containers, and containers decide component internals. Each level answers the question it is actually able to answer, and a component never has to know anything about the window it is rendered in.