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>
// ui/src/components/grid/index.tsx:43
export type Responsive<T> = T | Partial<Record<GridBreakpoint, T>>;// 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:
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.
// 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
| Primitive | Keys | Responsive props |
|---|---|---|
| Grid | base sm md lg xl | cols, rows, gap, rowGap, columnGap; GridItem colSpan, rowSpan, colStart/End, rowStart/End, order, alignSelf, justifySelf |
| Stack | base sm md lg xl | direction, gap, rowGap |
| Flex | base + md only | direction |
| AspectRatio | base + md | ratio |
| Container | not 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
// 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.
// 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. See07. - 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:. See04.
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.