Container queries
When a component should measure its own column instead of the viewport — named containers, the container scale (@md is 448px, not 768), and what container-type costs you.
A component in a design system does not know where it will live. The same card renders in a 320px sidebar and a 900px main column, inside the same browser window. A viewport query gives both the same answer, and it is wrong for one of them.
This is not a theoretical worry. In the 2026-09 research sweep, one product renders the same message-thread component at ~320px (four panes) that another renders at ~640px (two panes) — inside identically sized desktop viewports. A third product's thread pane is ~320px with its side rail open. Any viewport query would mis-serve two of those three.
The one trap that catches everyone
Container variants use Tailwind's container scale, which is not the viewport scale.
| Variant | Viewport scale | Container scale |
|---|---|---|
sm | 640px | 384px |
md | 768px | 448px |
lg | 1024px | 512px |
xl | 1280px | 576px |
// ui/src/components/navigation-menu/core.ts:298
// Tailwind's container scale in px — NOT the viewport scale
// (`@md` is 448, not 768).@md: and md: share a name and share nothing else. Writing @md: expecting
768 is the most common container-query mistake in this library.
And the expectation is reasonable, which is why it bites. A hand-rolled system normally reuses one ladder for both: one major production site ships 45 container rules and they break at 768, 1012, 1280, 1400, 544 — exactly its media-query numbers. Tailwind's separate container scale is the unusual choice here, not the obvious one. Check the scale every time.
What the axis actually is
From §9 (TES-57 round 3), and it is the sharpest statement of the rule:
The reflow axis is CONTENT, not width.
The useful question is never "how wide is the screen" but "what changed?"
- The page is reorganising → viewport query.
- The component's own column got narrow → container query.
- The content grew (longer label, translated string, 200% text zoom) → neither. Use flex basis and let it wrap.
- The input device changed → pointer query (
04).
The third case is the one people miss. Neither a viewport nor a container query
fires for Dynamic Type or 200% text zoom at a fixed width (§9, TES-72). Alert
shipped with container queries, measured them failing on exactly this, and
deleted them in favour of grow shrink basis-80 on the text block — the
actions move below once the text wraps, at whatever width that happens.
Full routing guide: 07-decision-guide.md.
Mechanics that are normative, not convention
An element cannot query itself
The spec selects the container "from among the element's ancestor query
containers". So a component needs a wrapper it owns as the container, plus an
inner element carrying the queried styles. You cannot put container-type and
the @container rules on the same node.
This is why Flex has no container mode (§9, TES-89): a Flex root cannot query
its own width, and the API would require consumers to supply an ancestor.
container-type: inline-size collapses intrinsic sizing
Normative (css-contain-2 §3.2):
the inline-axis intrinsic sizes of the principal box are determined as if the element had no content
So it is a trap on any content-sized box — inline-block buttons, tables, floats, and content-sized popovers, dropdowns and tooltips. Making one a container zeroes its shrink-to-fit width.
This is why §9 rejected @container for inline-flex: it "collapses the
shrink-to-fit capsule". Safe on block-level, fill-the-parent wrappers; a bug on
anything that sizes to its contents.
(Swept 2026-09-23: the only container-type sites in src/ are table.tsx and
table-skeleton.tsx, both on w-full boxes. Clean.)
cqi with no container silently becomes svw
If no ancestor is an eligible container, the spec says to "use the small viewport
size for that axis". It does not error. It computes a plausible-looking wrong
number. cqi and cqb on the same element can also resolve against different
containers.
Container names are global
Container names are not tree-scoped. A design system that publishes one can collide with a consumer's.
We name ours for the opposite reason — to avoid capturing theirs:
// ui/src/components/navigation-menu/navigation-menu-bar.tsx:135
// a bar inside an 850px panel on a 1440px screen has plenty of viewport
// and no room, and a media query cannot tell the difference. Named,
// because an anonymous `@container` would be captured by any container
// a consumer nests inside the bar.Name the container whenever a consumer might nest one inside your component. Anonymous is acceptable only for a leaf.
Emission order applies to container variants exactly as it does to max-*
This is not a separate rule; it is §5 wearing a different prefix, and it has shipped bugs twice.
✗ p-4 @md:p-6 // base wins at every width — probed live
✓ @max-md:p-4 @md:p-6 // two-sided, disjointEvery container/media pair is TWO-SIDED. A not(…) media twin replaces a
bare hidden beside a capability variant.
The theme-creator shipped eleven sites across six screens writing
w-full @2xl/screen:w-auto — and they are the best illustration of why the rule
is "never write the pair", not "never write the pair where it currently loses".
Measured in the production export on 2026-09-23, with the @container/screen
element's own width driven directly:
| 400px | 900px | |
|---|---|---|
w-full @2xl/screen:w-auto | 400 | 131.7 |
The variant wins here. Receipts from the shipped CSS, all in
@layer utilities, one occurrence each: .w-full at byte 74,816 against
.@2xl/screen:w-auto at 564,198 — the container variants land in the later run.
So this pair renders correctly today, by luck. An unrelated edit that moves
either class between runs flips it, exactly as .md\:px-6 flipped Container's
padding. Band it anyway.
Two lessons from measuring it:
- Dev is not an oracle for emission order. fumadocs' prebuilt CSS duplicates utilities, so the dev cascade differs from the export's. Measure the production build.
@2xlis 672px — confirmed by bisection (behaviour flips between 671 and 673), which is the container scale, not the viewport's.
See 04-pointer-and-touch.md Part 1 for why this happens at all.
Where the library uses containers today
Only three components — and that is deliberate. §9 (TES-42) says adopt it per task with that row as precedent, never as a sweep.
1. NavigationMenu — named, with three independent axes
// ui/src/components/navigation-menu/core.ts:289
sm: { full: "@max-sm/nav-bar:hidden @sm/nav-bar:flex",
compact: "@max-sm/nav-bar:flex @sm/nav-bar:hidden" },Three degradation axes at fixed thresholds:
| Axis | Threshold | Behaviour |
|---|---|---|
| actions | @xl 576px | CTA pair becomes one icon button |
| brand | @md 448px | lockup becomes the symbol |
| drawer | 384px | the bar becomes a hamburger + drawer |
Why fixed, not measured: "Every one of these frees the very space that triggered it — measuring would oscillate." The drawer axis is the exception and is safe to measure, because the root's width is set by its parent rather than its contents.
That distinction generalises: measure only when the trigger is independent of the outcome.
2. FileUpload — anonymous, and a worked example of the split
The zone auto-stacks below its own @md container width. Note what stays on the
viewport axis in the same file:
// ui/src/components/file-upload/shared.tsx:108
xs: "max-md:text-sm max-md:leading-5 md:text-xs md:leading-4"
// Phones floor at 14px (§5b)Layout arrangement on the container axis; type floors and device capability on the viewport/pointer axis. One component, both axes, on purpose — a narrow desktop column is not a phone.
3. Table — [container-type:inline-size] on a w-full scroll wrapper
The safe shape: a block-level, fill-the-parent box.
What is NOT a container query
A sheet. Container-width switching was explicitly rejected for ArtifactPanel
(§9, TES-392) because a sheet is a viewport-level overlay — it escapes its
box to cover the screen, so the container it sits in tells you nothing about the
space it will occupy. It forks at (max-width: 767px).
Container gutters. Container's padding follows the viewport on purpose
(§9, TES-88): a container query styles descendants of the query container, so
Container cannot query its own width for its own padding, and cqi units would
need the parent to be a size container. Inside a narrow pane on a wide screen,
pick a fixed padding.
Touch floors. Keyed to the pointer, always. See 04.
Testing a container query
Resizing the browser does not test one. The component must be measured inside a narrow container at a wide viewport — that is the state a viewport query gets wrong, and the only state that proves the container query fires.
House test widths (TESSINA_UI_QA_FLOW.md §2.2): 240 (sidebar/card),
320 (half-column), 480 (modal/drawer), fluid.
A padded stage breaks a component at 375 exactly as 320 full-bleed does, and a viewport media query never fires for it.
The scaled-frame technique
The theme-creator previews a tablet and a desktop inside a phone-sized pane by
laying the frame out at full logical size (834×1112, 1440×900) and drawing it
scaled with a --k variable from a ResizeObserver:
// docs/app/theme-creator/tablet-frame.tsx:22
// The glass keeps its full 834×1112 LAYOUT size and is only drawn smaller:
// screens adapt with `@2xl/screen:` container queries, which measure layout
// size, so the tablet layout holds at any scale.Container queries measure layout size, not drawn size, so @2xl/screen
(42rem) still fires correctly at any scale. A viewport query would report the
browser window and be wrong for every frame. This is the cleanest demonstration
in the codebase of why the axis matters.
Support
container-type and @container: Chrome 105, Firefox 110, Safari 16.
Style queries are custom-property-only (Chrome 111, Safari 18, Firefox 151);
style queries for regular CSS properties are implemented nowhere.
Next: 07-decision-guide.md to choose between this and the other axes, or
06-layout-primitives.md for the viewport-banded APIs.