DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

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.

VariantViewport scaleContainer scale
sm640px384px
md768px448px
lg1024px512px
xl1280px576px
example.ts
// 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:

example.ts
// 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.

example.plaintext
✗ p-4 @md:p-6            // base wins at every width — probed live
✓ @max-md:p-4 @md:p-6    // two-sided, disjoint

Every 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:

400px900px
w-full @2xl/screen:w-auto400131.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.
  • @2xl is 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

example.ts
// 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:

AxisThresholdBehaviour
actions@xl 576pxCTA pair becomes one icon button
brand@md 448pxlockup becomes the symbol
drawer384pxthe 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:

example.ts
// 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:

example.tsx
// 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.

Mobile-first doctrinePointer, touch and emission order

On this page

The one trap that catches everyoneWhat the axis actually isMechanics that are normative, not conventionAn element cannot query itselfcontainer-type: inline-size collapses intrinsic sizingcqi with no container silently becomes svwContainer names are globalEmission order applies to container variants exactly as it does to max-*Where the library uses containers today1. NavigationMenu — named, with three independent axes2. FileUpload — anonymous, and a worked example of the split3. Table — [container-type:inline-size] on a w-full scroll wrapperWhat is NOT a container queryTesting a container queryThe scaled-frame techniqueSupport