DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

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

Namexsmobilesmmdlgxl2xl
Width320375640768102412801536

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:

example.plaintext
✗ 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-12

The 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.

FamilySourcexssmmdlgxlTouch
Input (typed text)fieldTextSizeMap (field)121414161616 at every step
Input chromecontainer size axis1214141616as desktop
Field metafield.tsx label/helper1212121414as desktop
Menu listitemSizeVariants (dropdown-menu)1212141618as desktop
Bar controlmenubar, tabs inline, segmented1214141618as desktop
Selection labelselectionLabelTextSizeMap (checkbox)1214141618as desktop
ButtonBUTTON_SIZE1414141624 heroas 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:

  1. Target sizes and the gaps between them → pointer (touch:/no-touch:).
  2. A component in a consumer's column → container query.
  3. 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.
Responsive designMobile-first doctrine

On this page

The breakpoint ladderOur ladder is in px, and that is a real gapEvery rung needs a ceilingThe type ladderFloor 1 — type bottoms out at 12pxFloor 2 — inputs are 16px on touchWhat is not on the width axisTwo gaps in this ladder