Breakpoints and foundations
The breakpoint ladder, why every rung needs an upper bound, the type ladder, and the 12px and 16px floors.
The ladder, the type scale, and the two floors. Short file; most of the depth
lives in 04 (pointer and emission order) and 02 (the doctrine).
The breakpoint ladder
All seven are "must pass" (TESSINA_UI_QA_FLOW.md §2.1):
| Name | xs | mobile | sm | md | lg | xl | 2xl |
|---|---|---|---|---|---|---|---|
| Width | 320 | 375 | 640 | 768 | 1024 | 1280 | 1536 |
Tailwind's defaults plus two device widths. Where they stand against the industry
— 320 is near-universal, 375 is ours alone, 768 is well supported but not
universal — is in 02 §2 and the research ledger,
ui/knowledge/responsive/_research-ledger.md (in the source repo; it does not
ship in the package). Do not present the ladder as a design-system consensus,
because it isn't one.
Container widths are a separate axis: 240 (sidebar/card), 320 (half-column),
480 (modal/drawer), fluid. A component breaks in a narrow container at 375
exactly as at 320 full-bleed, and a viewport query never fires for it. → 03
Our ladder is in px, and that is a real gap
Both government design systems emit their breakpoints in em, not px — one
of them makes it a named, default-on setting. The consequence they state
plainly: a user whose browser default font is 24px crosses the tablet breakpoint
at 960 CSS px, not 640.
Ours are px. So a user who has enlarged their default text gets the same layout switch points as everyone else, when what they need is for the layout to change earlier — their text is bigger, so less of it fits.
This is not a defect we have measured, and changing the ladder's unit would re-flow every component. But it is the sharpest conflict any survey round has produced, and the two systems that disagree with us are the two with legal accessibility obligations. Treat it as a known gap with a named cost, not as settled.
One thing worth stealing regardless of units: bake the responsive behaviour
into the utility so the author never names a breakpoint and therefore cannot get
it wrong. A half-width utility is width: 100% and only becomes 50% from
tablet; the grid helper takes the breakpoint as a defaulted parameter. No
breakpoint appears in any of their class names.
Every rung needs a ceiling
The single rule that makes the ladder usable:
✗ px-4 md:px-6 lg:px-8 xl:px-12
✓ max-md:px-4 md:max-lg:px-6 lg:max-xl:px-8 xl:px-12The first shipped as Container's default padding and measured 24px at a 1400px
viewport, where xl:px-12 asks for 48. .md\:px-6 was emitted twice and the
late duplicate beat both rungs above it.
A ladder of N steps needs N disjoint bands. Not one bare utility plus N−1
variants; not N variants that happen to be ordered correctly today. Full
mechanism, and the five other faces of the same failure, in 04 Part 1.
The type ladder
size is density — height, padding, icon optics. Whether type steps with
it is a family decision with one source per family, never a per-component
map.
| Family | Source | xs | sm | md | lg | xl | Touch |
|---|---|---|---|---|---|---|---|
| Input (typed text) | fieldTextSizeMap (field) | 12 | 14 | 14 | 16 | 16 | 16 at every step |
| Input chrome | container size axis | 12 | 14 | 14 | 16 | 16 | as desktop |
| Field meta | field.tsx label/helper | 12 | 12 | 12 | 14 | 14 | as desktop |
| Menu list | itemSizeVariants (dropdown-menu) | 12 | 12 | 14 | 16 | 18 | as desktop |
| Bar control | menubar, tabs inline, segmented | 12 | 14 | 14 | 16 | 18 | as desktop |
| Selection label | selectionLabelTextSizeMap (checkbox) | 12 | 14 | 14 | 16 | 18 | as desktop |
| Button | BUTTON_SIZE | 14 | 14 | 14 | 16 | 24 hero | as desktop |
A new Record<Size, "text-…"> inside a component whose family already has a
ladder is a violation. scripts/text-ladder/ gates it; re-record with
pnpm ladder:baseline so the change shows up in review.
A line-height goes AFTER the ladder's size, never before it. cn() is
tailwind-merge, where a font size conflicts with a line-height and not the
reverse: cn("leading-none", SIZE[size]) deletes the leading-none and the
size's default renders (16px for text-xs, 20 for text-sm, 24 for
text-base). ActivityHeatmap's label rows were 16px at every cell size that
way (TES-452), and 46 more classes across 19 components never reached the DOM
(CONVENTIONS §9 2026-09-30). Write cn(SIZE[size], "leading-none") or one
class, text-xs/none. A size under a variant (touch:text-base no-touch:text-xs) does not delete a bare leading-*, so one map can drop it
at lg and keep it at xs — Textarea's control did. scripts/leading-merge-order/
gates the order in test:ci; it cannot see a class list built by a helper
call or a className passed across components, so read the computed
line-height when a row's height looks one step off.
Height ladders must step at least 12px between sm and lg — at 8px the two
sizes read as one. Compact scales (Shortcut keycap, Badge, Sidebar row, Toolbar,
Calendar day cell, Pagination) keep 8px steps on purpose: density is their job.
Floor 1 — type bottoms out at 12px
No component text renders under text-xs. A site that no longer fits its box at
12px re-derives the box — or, like circular Progress below lg, moves the
text somewhere that holds it.
Exceptions are compact marks whose box cannot hold a 12px line: Shortcut xs
keycap and separator, Badge sm, Avatar xs/sm initials, Switch in-track
ON/OFF. Each carries a // type-floor-exception(TES-n): <why> comment and a §9
row.
scripts/type-floor/ counts sub-12px arbitrary sizes per file. It cannot see
inline style font sizes.
Be honest about what this floor is. It is a house legibility choice, not a WCAG requirement. WCAG Reflow mandates no text enlargement at all, and its Understanding document works an example where text is deliberately reduced at the reflowed breakpoint and still conforms. Do not defend the floor as accessibility.
And it is low. The most accessibility-rigorous system surveyed has nothing in its entire type scale below 16px — its body copy is 19px at every viewport, and its dense-table modifier only drops to 16px. The other government system's themed floor is 13px. Our 12px sits 4px under one and 1px under the other. That is a deliberate density choice, and anyone citing those systems as authorities elsewhere should know we depart from them here.
Floor 2 — inputs are 16px on touch
iOS Safari zooms the page when a focused text control renders under 16px. iPads and landscape iPhones are ≥768px, so keyed to width they took the desktop half and zoomed.
fieldTextSizeMap is touch:text-base no-touch:text-*. Seventeen components
inherit it with no edit. A native <select> or <input> that does not read the
map still takes the floor, written by hand on the pointer axis.
If a user can type into it, it is ≥16px on touch. → 04 Part 2
What is not on the width axis
Three things people reach for a breakpoint to solve, incorrectly:
- Target sizes and the gaps between them → pointer (
touch:/no-touch:). - A component in a consumer's column → container query.
- Text that got longer — translation, Dynamic Type, 200% zoom → neither query fires. Wrap by flex basis.
→ 07-decision-guide.md
Two gaps in this ladder
Named rather than hidden:
- No height axis. A landscape phone at 900×400 passes every width breakpoint we own and is still a bad two-pane target. Only one surveyed system defines a height ladder.
- Nothing above 1536. Only one surveyed system specifies behaviour past its top breakpoint. We say nothing about a 2560px monitor.