DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

Mobile-first doctrine

Design order, thumb zone, no hover-only, sheets below md, safe area, the keyboard-up layout state, dvh, and what WCAG F102 actually forbids.

This is the canonical text of CONVENTIONS.md §5b. That section carries the normative summary; this file carries the reasoning, the measurements and the failures each rule was written from.

It was cited as binding from 2026-09-03 and did not exist until 2026-09-23. Its draft lived on chore/mobile-first-convention, which was never merged and is gone from every ref. Two ledgers noticed and said so in writing; nothing failed, so nothing changed. scripts/knowledge-lint/ now fails the build if either half goes missing again.


1. The design order

Build for the smallest screen first and scale up. Base (unprefixed) styles are the mobile case; larger screens are the override, never the reverse.

Verify at 375 first, then 320, then upward. Touch targets, spacing and type must be comfortable at the small end before any desktop polish — a desktop layout squeezed down keeps the desktop's assumptions and loses at every one.

This is not only an aesthetic order, it is a correctness order. §5's emission-order rules mean a bare utility written as "the desktop default" beats its own max-* override at every width. Designing small-first produces the class strings that survive the cascade; designing large-first produces the ones that quietly don't. See 04-pointer-and-touch.md.


2. The breakpoint ladder

All seven are "must pass" in TESSINA_UI_QA_FLOW.md §2.1:

xsmobilesmmdlgxl2xl
320375640768102412801536

Be honest about where these came from. They are Tailwind's defaults plus two device widths — not a design-system consensus.

Against the surveyed systems: 320 is the only near-universal value — it is also the width WCAG 1.4.10 Reflow is defined against. 375 is ours alone; no surveyed system has any breakpoint between 320 and 480. 768 is matched by exactly one of the six surveyed systems, 640 and 1024 have partial support, and past 1024 no two surveyed systems agree. One surveyed ladder is better-founded than ours — whole rem counts derived from column math rather than powers-of-two viewport guesses — and we keep ours because changing it now would re-flow every component, not because it is more principled.

What production sites actually ship

Measured from the live CSSOM of five high-traffic sites (rule counts, so they weight by how load-bearing each breakpoint is), the three best-engineered sites disagree irreconcilably about 768–1024 — worth knowing before anyone claims our md is standard. One makes 768 the main event: at 640 it is one column with 4 visible navs; at 768 a second column appears, an aside becomes visible, and navs jump 4 → 7, a shape that then holds unchanged through 1280. One treats the whole band as desktop — its dominant breakpoint is 641 and it names 769 "desktop"; the most accessibility-rigorous site surveyed puts desktop at the bottom of the band. One skips the band entirely — 640 straight to 1120.

They agree only that something changes between 640 and 769. Our md at 768 is defensible and is a choice, not a convention.

The clearest argument for keying density to the pointer

One surveyed system keys control height to the viewport — max-md: and max-sm: utilities grow some controls to 40px below 601/401px. It does not work, and the failure is exactly the one our doctrine predicts: a 1024px touch tablet gets the 32/36px control, while a 380px desktop window gets the 40px one. Both answers are backwards, and no amount of tuning the breakpoint fixes either.

Container widths are a separate axis

Components are also tested at 240 / 320 / 480 / fluid container widths, because the system is consumed in layouts we do not control.

"Components are tested by container width, not just page width." — TESSINA_UI_QA_FLOW.md §2

A component inside a padded stage breaks at 375 exactly as 320 full-bleed does, and a viewport media query never fires for it. Which axis to use is 07-decision-guide.md; the mechanics are 03-container-queries.md.

Two gaps in this ladder, named rather than hidden

  1. No height ladder. Only one surveyed system defines one (compact height < 480dp, expanded ≥ 900dp) and it warns that two-pane layouts are impractical at compact height. A landscape phone at 900×400 passes every width breakpoint we own and is still a bad two-pane target. We have no rule for it.
  2. Nothing above 1536. Only one surveyed system specifies behaviour past its top breakpoint (centred / left-aligned / add columns in twos). We say nothing about a 2560px monitor.

3. The thumb zone

A phone is held one-handed more often than not, and the bottom third of the screen is the only comfortably reachable region. Two consequences:

  • Primary actions live at the bottom, full-width, clear of the home indicator. This is the strongest convention in the research: 13+ products, ~16–20pt side gutter, and no observed case of a button sitting under the home indicator.
  • Destructive actions do not live where the thumb rests without a confirmation step.

The industry's own anti-pattern, recorded so we do not copy it: five surveyed products place a raised + at the single most reachable point of the screen — spending the best real estate on a create action while pushing actual destinations to the hardest-to-reach corners.

Button order flips with the axis, and a naive stack inverts it

Measured across the sweep:

  • Stacked on phone → destructive on top, safe below (four products).
  • Side-by-side, phone or desktop → safe left, destructive right (eight products).

A single DOM order cannot satisfy both. flex-col md:flex-row silently reverses which button reads as primary — and it is also an emission-order violation (§5). Write disjoint bands, and set the order explicitly per band rather than letting the axis decide it.


4. Nothing is hover-only

An affordance reachable only by hover does not exist on a touch device.

This extends further than it first appears:

  • Pointer-only hints are the same failure wearing a keyboard. One surveyed product prints ⌘+⏎ inside its Back/Next labels in a touch-capable bar.
  • Icon-only navigation whose label lives in a tooltip fails this rule. ~10 products ship it, and not one of 84 surveyed products ships a pointer-aware rail. If we ship an icon-only rail it must show labels whenever touch: matches. Recorded as rejected convention R3 in the research ledger, ui/knowledge/responsive/_research-ledger.md (in the source repo; it does not ship in the package).

Gate the capability, not the width: (hover: hover) and (pointer: fine) for hover affordances, touch: for target sizing. §9's TES-42 row draws this line explicitly — layout arrangement on the container axis, device capability on media queries.


5. Below md, a dialog becomes a sheet

  • A short confirm may stay centred. Two of ten surveyed phone confirms did — the iOS native alert idiom. "Centred dialog always becomes a sheet on phone" is false as stated.
  • A long form presents as a tall sheet with the parent peeking, not an opaque takeover (six products).
  • Dismissible sheets carry a grabber — ~36×5pt pill, centred.

A sheet switches on the VIEWPORT, never on a container. This is a recorded decision, not a preference: a sheet is a viewport-level overlay, so container-width switching was explicitly rejected for ArtifactPanel (§9, TES-392). The component forks at (max-width: 767px).

Note the asymmetry with §7 below: layout arrangement keys to the container, but anything that escapes its box to cover the screen keys to the viewport.


6. The safe area

Edge-anchored surfaces (drawer, sheet, toast) use env(safe-area-inset-*) directly. Positioned popups grow their collisionPadding through lib/safe-area.ts (§7b.4) — a menu that stops 8px from an iPhone's bottom edge is sitting inside the ~34px home-indicator strip, where the system swipe takes the touches.

Three mechanics that are easy to get wrong:

  • env() is CSS-only. It is not exposed to script, and getPropertyValue returns the unresolved token stream. useSafeAreaInsets reads it with a hidden fixed probe.
  • Playwright reports every inset as 0. The probe carries data-slot="safe-area-probe" precisely so a test can override what it resolves to — that is the only way to exercise a notched device.
  • Rotation changes which edges are reserved, so the read subscribes to orientationchange as well as resize.

7. Keyboard-up is a third layout state

Not a variation on "mobile" — a distinct state, and the one existing house rules did not cover.

The finding that forced this section: twelve products dock the sticky action bar to the top edge of the keyboard, not the bottom of the viewport. It was the strongest single signal in the sweep.

The consequence is what matters: while the keyboard is up it covers the home indicator, so safe-area-inset-bottom is the wrong inset. Both the touch: floor and the safe-area rules implicitly assume keyboard-down.

House mechanics:

  • Every side="bottom" Drawer wraps Drawer.VirtualKeyboardProvider and reads --drawer-keyboard-inset twice, CSS-only: padding-bottom: max(env(safe-area-inset-bottom), var(--drawer-keyboard-inset, 0px)). The 0px fallback is load-bearing.
  • Never set interactive-widget=resizes-content — the provider measures the visual viewport itself.
  • innerHeight - visualViewport.height has stopped being a keyboard measurement. iOS 26 Safari shrinks the layout viewport too. Measured on iPhone 15 Pro / iOS 26.6.2: innerHeight 695→470, vv.height 358 — the old clamp returned 0. The keyboard is now classified against the tallest viewport seen at the current width, on a coarse pointer only, re-anchoring when innerWidth changes (§9, TES-348). In that branch inset is 0, and that is the honest answer: the shrunken layout viewport's bottom edge already sits on the keyboard.
  • Keep the focused field visible. A sticky bar with no matching scroll padding clips content — one surveyed product cuts "Date of birth" mid-glyph behind its own docked Continue bar.

8. dvh, not vh

Size anything screen-height with dvh, and clamp against 100dvh. vh is the largest viewport height and ignores retracting browser chrome, so a 100vh element is taller than the screen for most of a scroll on mobile.

Where a drawer combines the two: max-height: min(calc(<size>dvh + inset), 100dvh).

Separately: iOS 26's 100svh can exceed the space above a fixed bottom bar. Measure rather than assuming the units compose.


9. Never trap scroll

A locked overlay must release cleanly. Two hooks exist on purpose:

  • useTouchScrollLock — iOS only. Base UI's useScrollLock selects with isIOS || !hasInsetScrollbars(...), so iPhone and iPad always take the overlay-scrollbar branch, whose entire body is overflow: hidden. That stops a wheel and does nothing to a finger. The stronger branch is unreachable on iOS by construction, so it cannot be fixed by configuring or upgrading Base UI. The lock is position: fixed + top: -scrollY, restored on release — the only thing WebKit respects.
  • usePageScrollLock — every platform, for overlays with no other lock.

Running both off iOS would mean two locks fighting over the same styles, which is why this is two hooks and not one.

Knock-on effects worth knowing: the lock stamps data-tessina-scroll-locked on <html>, and use-hide-on-scroll bails while it is present — the iOS lock parks the body at scrollY 0 and jumps back on release, and both edges read as giant scrolls.

Also: overflow: hidden clips visually but leaves the box programmatically scrollable. Assert that a box cannot be scrolled; do not trust overflowX.


10. Motion is gated ON

Write motion-safe:, not motion-reduce: — see §4 and 04-pointer-and-touch.md. A bare transition-colors beside motion-reduce:transition-none loses to emission order; measured in the docs build, the bare utility was emitted ~200KB later and won, leaving 29 components animating for everyone.

Carry the gate through duration too. transition-property's initial value is all, not none, so motion-safe:transition-colors duration-200 hands reduced motion every property at 200ms — worse than doing nothing.


11. Never make meaning UNREACHABLE

WCAG F102 — a failure of 1.4.10 Reflow (not 1.3.1; the mis-citation is common).

The test is reachability, not visibility. This is the part most people state too strongly, including an earlier draft of this file. F102's own text says content at a narrow width "should still be available after reflow … either by being repositioned in a single column view, or through some interaction offering the information in some other way, for example, in a disclosure area, a dialog, or via a link to another view."

The Reflow Understanding document goes further and names the hamburger as a conforming pattern: "the navigation changes first to hide options behind a 'More' dropdown menu. As zooming continues, most navigation options are eventually behind a 'hamburger' menu button. All the information and functionality is still available."

So: hiding behind a control is fine; disappearing is not. All five of F102's listed failure examples are unreachability — a sidebar block that vanishes with no equivalent, labels replaced by placeholder-only text, a search field with no icon or equivalent.

Small screens get the same capability set, reorganised. Do not reduce functionality or remove content for smaller viewports: a user reaches the same content whatever the window. A dense data table at 388px still offers sort, filter, pagination, selection, column preferences, sticky columns and per-row actions — nothing dropped, nothing behind a breakpoint. Row actions collapse into a kebab overflow, which is relocation, not removal.

Density may be a user preference — and must not eat the floor

A compact density mode and a 44px touch floor can coexist, but only under one condition: the compact scale is not fully applied to elements with limited target space — the dropdowns in select, multiselect, autosuggest, date picker — and is meant for full-page, data-intensive views.

A density reduction never touches a control that is already at its target-size minimum. That clause is what keeps a compact mode from quietly deleting the floor. The rest follows: comfortable is the enforced default, the preference is persisted, and compact mode does not replace comfortable mode.

Say what it costs: reduced spacing can hinder readability for users with vision impairment, and interaction with close targets for users with physical disability.

The reflow numbers, exactly

Reflow requires content at 320 CSS px wide (vertical scrolling content) or 256 CSS px tall (horizontal scrolling content) without two-dimensional scrolling. 320px is "equivalent to a starting viewport width of 1280 CSS pixels at 400% zoom" — 400% applies to the dimension, not the area.

Two practical notes:

  • Test at 320×256 directly, not by zooming a 1280px window. WCAG's own worked example measures a 1280×1024 laptop at 400% as 318×236 once scrollbars and browser chrome are subtracted.
  • The two-dimensional exemption is scoped, not blanket. Maps, diagrams, video, games and data tables are exempt as layouts, but "each cell within a table would still need to meet this success criterion."

Reflow does not require text enlargement. WCAG explicitly works an example where text is reduced at the reflowed breakpoint and still conforms. Our 12px type floor is therefore a house legibility choice, not a WCAG requirement — do not defend it as one.

Note that two major systems disagree with us here — one lists show/hide among its responsive techniques, the other ships breakpoint-keyed display: none helpers — and we reject both.

The rejected convention worth knowing about: the industry drops the labelled step list from multi-step forms at phone width, replacing it with an unlabelled progress hairline (two products) or nothing at all (eight products). "Which step am I on, of how many, and what remains" is meaningful content. Take the one form in the corpus that got it right instead — it prints "Step 2 of 2" as text alongside the graphic, which is the compact representation that survives a narrow width without losing the information.

The related trap: neither query fires for text zoom

A viewport query and a container query both measure width. Dynamic Type and 200% text zoom change neither. A layout that only reflows on a width query will overflow at a fixed width when the text grows — which is why §9's TES-72 row moved a trigger's trailing value to stacking by content, using flex basis, rather than by any breakpoint at all.

What spills a 320px edge without breaking any rule (2026-09-27, TES-588)

The harness drops its padding at 320px, so a component there sits flush against the viewport, and anything drawn past the component's own box becomes page scroll. Nine examples failed this way once every example was measured. The lessons:

  • Visible content stays inside the box. A label centred on a slider's first or last tick hung 2px past the track. The end labels now grow inward from the tick; the tick itself never moves.
  • Invisible room outside the box needs a declared edge. A 44px tap pad on a 40px button, and a scroll row's 12px bleed for rings and fade, both live in the page gutter. Only the page knows whether a gutter exists, so it is opt-in: IconButton tapEdge, ChipGroup flush. Never remove the room for everyone.
  • A centring parent unbounds max-w-full. items-center shrink-wraps a child to max-content, and its max-w-full then resolves against that. Tabs caps its own root for this reason, and a skeleton must copy its component's caps: TabsSkeleton spilled wherever the real strip fitted.
  • className on a root that holds two rows can lay out neither. TimeRange's root gained an error row, and the stacking an example did with className silently stopped. Expose the inner row as a data-slot hook.
  • Rejected: truncating ButtonGroup labels. The equal-width cells are narrower than their widest label at every width (the group sums natural widths, then flex-1 splits equally), so truncation showed "Und…" at 1280px. The fix is the sizing model (TES-591); until then use stackOn="auto".

Where to go next

  • The class-string mechanics that make any of this actually render: 04-pointer-and-touch.md
  • Choosing the adaptation axis: 07-decision-guide.md
  • The pattern catalogue with worked code: 05-adaptive-patterns.md
  • Evidence and product names behind every claim here: the research ledger
Breakpoints and foundationsContainer queries

On this page

1. The design order2. The breakpoint ladderWhat production sites actually shipThe clearest argument for keying density to the pointerContainer widths are a separate axisTwo gaps in this ladder, named rather than hidden3. The thumb zoneButton order flips with the axis, and a naive stack inverts it4. Nothing is hover-only5. Below md, a dialog becomes a sheet6. The safe area7. Keyboard-up is a third layout state8. dvh, not vh9. Never trap scroll10. Motion is gated ON11. Never make meaning UNREACHABLEDensity may be a user preference — and must not eat the floorThe reflow numbers, exactlyThe related trap: neither query fires for text zoomWhat spills a 320px edge without breaking any rule (2026-09-27, TES-588)Where to go next