DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

Layout primitives

Responsive<T> on Grid, Stack and Flex, Container's gutter bands, and what the primitives deliberately will not do.

Grid, Stack, Flex, Container and AspectRatio take responsive values as objects. The objects exist so you do not hand-write band ladders — and so the emission-order rules of 04 are enforced by construction rather than by discipline.


Responsive<T>

example.ts
// ui/src/components/grid/index.tsx:43
export type Responsive<T> = T | Partial<Record<GridBreakpoint, T>>;
example.ts
// ui/src/components/grid/index.tsx:32
export type GridBreakpoint = "base" | "sm" | "md" | "lg" | "xl";

Keys forward-fill. { base: 1, lg: 3 } is one column below lg and three from lg up. An object with no base leaves widths below its first key unset — always give base.

Responsive<T> itself is not re-exported from the package root; the per- primitive aliases are (GridBreakpoint, StackBreakpoint, FlexResponsiveDirection, ResponsiveAspectRatio).


The five bands

Grid established them (TES-90); Stack adopted them unchanged (TES-92). The table is written identically in both files:

example.plaintext
base  max-sm:      (           width <  40rem )
sm    sm:max-md:   ( 40rem  <= width <  48rem )
md    md:max-lg:   ( 48rem  <= width <  64rem )
lg    lg:max-xl:   ( 64rem  <= width <  80rem )
xl    xl:          ( 80rem  <= width          )

Every band carries its own upper bound. That is the point: a min-width ladder without ceilings is decided by emission order, and Container's px-4 md:px-6 lg:px-8 xl:px-12 measured 24px at 1400px because .md\:px-6 was emitted twice and the late duplicate won. 04 Part 1.

example.ts
// ui/src/components/stack/index.tsx:118
const DIR_BANDED: BandedMap<StackDirection> = {
  base: { horizontal: "max-sm:flex-row",    vertical: "max-sm:flex-col"    },
  sm:   { horizontal: "sm:max-md:flex-row", vertical: "sm:max-md:flex-col" },
  md:   { horizontal: "md:max-lg:flex-row", vertical: "md:max-lg:flex-col" },
  lg:   { horizontal: "lg:max-xl:flex-row", vertical: "lg:max-xl:flex-col" },
  xl:   { horizontal: "xl:flex-row",        vertical: "xl:flex-col"        },
};

What each primitive accepts

PrimitiveKeysResponsive props
Gridbase sm md lg xlcols, rows, gap, rowGap, columnGap; GridItem colSpan, rowSpan, colStart/End, rowStart/End, order, alignSelf, justifySelf
Stackbase sm md lg xldirection, gap, rowGap
Flexbase + md onlydirection
AspectRatiobase + mdratio
Containernot Responsive<T> — padding="responsive" is one fixed 4-band string—

Flex is deliberately narrower. Other switch points (sm, lg) and responsive align/justify/wrap are backlog, not oversight.


Two implementation details worth knowing

The object form emits NO bare class

example.ts
// ui/src/components/flex/index.tsx:151
// 'null' switches cva's default off: a bare 'flex-row' beside the bands
// would race them exactly like a hand-written className pair.

resolveDirection returns { variant: null, bands: [...] }. If it returned the cva default alongside the bands, the primitive would ship the very bug it exists to prevent.

A single distinct value collapses back to a scalar

resolveResponsive forward-fills across the band order, then: if only one distinct value results and base was given, it emits the bare utility instead of five bands. { base: 2, sm: 2, md: 2 } is just grid-cols-2.

Fewer classes, and no bands to mis-order.


Gaps you will hit

Flex cannot do container mode

@max-md: / @md: bands on Flex are backlog with a recorded reason (§9, TES-89): a container query resolves against an ancestor, so a Flex root cannot query itself, and container-type: inline-size zeroes the intrinsic inline size of an inline or content-sized Flex. It would need an opt-in API plus a consumer-supplied @container ancestor.

Container cannot query its own gutters

Same root cause (§9, TES-88). Container's padding follows the viewport on purpose. Inside a narrow pane on a wide screen, pass a fixed padding.

example.plaintext
// ui/src/components/container/index.tsx:44  — four disjoint bands, 16→24→32→48
max-md:[--container-px:1rem] md:max-lg:[--container-px:1.5rem]
lg:max-xl:[--container-px:2rem] xl:[--container-px:3rem]

Container's geometry is published as --container-size / --container-px, and padding is logical (ps-*/pe-*, never px-* — different tailwind-merge groups, see 04 Rule 6).

Size scale: xs 448 · sm 672 · md 896 · lg 1152 · xl 1280 · 2xl 1536 · full 100%. Defaults: size="lg", padding="responsive", center.

max-md:hidden on a Flex hits the bare-flex trap

Flex emits a bare flex. Adding max-md:hidden beside it is a bare utility against a variant of the same property — display. Band both sides.


When not to reach for a primitive

  • The trigger is content, not width → flex basis. grow shrink basis-80, not a breakpoint. See 07.
  • The component lives in a consumer's column → container query on a wrapper you own. The primitives are viewport-banded only. See 03.
  • It is a target size → touch: / no-touch:. See 04.

One rule from outside the primitives

A docs example whose root is a layout wrapper rather than the component itself carries w-full (§9, TES-83).

useFramedZoom writes only transform: scale() onto the example root — never a width, and it creates no wrapper. A widthless root shrink-to-fits to max-content and every w-full inside it resolves against ~0. Three radar examples rendered at 48px while every gate stayed green.

The gate cannot see it: the QA harness renders at /qa-harness/[id], where <main> is a plain block and every example has a definite width, so this class of defect is structurally invisible there.


When a layout primitive is the wrong tool

Worth reading before extending the responsive-object surface further.

A layout primitive has to earn its place over plain CSS. The category's standing costs are a bloated prop interface, blindness to the theme, and an abstraction over CSS the reader already knows. Ours earn their place by emitting disjoint bands you would otherwise have to write by hand correctly every time (see 04). Where that is not the value being added — a static two-column grid, a simple stack — plain utilities are the better answer: plain n-column responsive content is grid grid-cols-* written directly.

Never nest a layout primitive more than one level. Deeper nesting signals a layout that is too complex and needs design refactoring, not another wrapper.

Responsive values resolve in CSS, never in script. A resolver that runs in JavaScript has no viewport on the server, so it has to guess: one that scans its breakpoints in descending order picks the largest, paints a phone's first frame at the desktop value, and corrects after hydration. A value resolved in CSS has no pre-hydration wrong answer to correct.

Reading the numbers honestly

Our five bands are Tailwind's defaults. Of the nine design systems surveyed, one ladder is better-founded than ours — whole rem counts (42/66/82/99rem) derived from 16-column math rather than powers-of-two viewport guesses, which is why its values sit systematically 32–48px above ours.

We keep ours because changing them now would reflow every component, not because they are more principled. See the research ledger, ui/knowledge/responsive/_research-ledger.md (in the source repo; it does not ship in the package), for the full comparison.

Adaptive patternsChoosing the adaptation axis

On this page

Responsive<T>The five bandsWhat each primitive acceptsTwo implementation details worth knowingThe object form emits NO bare classA single distinct value collapses back to a scalarGaps you will hitFlex cannot do container modeContainer cannot query its own guttersmax-md:hidden on a Flex hits the bare-flex trapWhen not to reach for a primitiveOne rule from outside the primitivesWhen a layout primitive is the wrong toolReading the numbers honestly