Adaptive patterns
Navigation, list-detail, tables, forms, action bars, overlays and empty states across form factors — what the industry does, what we do, and why they differ.
The catalogue. Each pattern states what the industry actually does, what we do, and why they differ where they differ.
How to read the evidence tags. A behaviour seen in ≥3 independent products
is a convention (two products from one owner count once). A behaviour unique to
one product is that product's identity and we do not copy it. Product names and
counts are in the research ledger, ui/knowledge/responsive/_research-ledger.md
(in the source repo; it does not ship in the package).
A standing caveat on thresholds. The screenshot corpus has no tablet tier and no viewport metadata. No px threshold in this file is sourced from it — every number comes from WCAG, the platform, or our own measurement. Where a threshold is genuinely unevidenced, this file says so rather than inventing one.
1. Navigation
What the industry does
- Phone: a persistent bottom tab bar, 3–5 slots, icon over label. 22 of 22 products; not one exceeded 5 slots.
- Slot 5 becomes "More" → a full-screen grouped list of 10–20 items.
- The centre slot is often a raised action, not a destination — so the real ceiling is 4 destinations + 1 action.
- Desktop: a left rail or sidebar, never a bottom bar (27 of 29 app-like products). Rails cluster at ~48px icon-only or ~64–72px icon+label; expanded sidebars at ~200–260px, clustering near 224px.
- A bottom-anchored utility cluster, visually separated from destinations (~14 products) — the strongest desktop convention in the sweep.
The finding that should change your mental model
There is no width-triggered rail. In all eight products where the control is visible, rail ↔ sidebar is a user toggle operated at desktop width and persisted as a preference. Zero instances of a viewport-triggered rail.
The usual "tablet gets a rail" framing is not an observed convention.
Our theme-creator's desktop rail is therefore a house decision, not industry precedent. Say so when you defend it.
What we do
// docs/app/theme-creator/flows/flow-shell.tsx:82
// phone + tablet: nav shows only on root screens.
// desktop: it becomes a left rail and stays on pushed screens too.-
Phone → bottom bar; desktop → left rail. The transformation is bottom → left, not bottom → top. Top nav is for marketing sites and desktop-first app shells.
-
Collapsing preserves item count and order. Labels go; destinations do not. Nothing is dropped.
-
The nav persists on desktop detail screens. It disappears only in an explicit, user-invoked focus mode.
-
Active indicator is medium-specific, and the rule is clean because no product in the sample broke it:
Medium Indicator Vertical list (sidebar) filled/tinted full-width row Icon rail filled square behind the icon Bottom bar icon + label tint Horizontal strip underline — and only here
Two rules that are ours, not theirs
An icon-only rail must show labels whenever touch: matches. ~10 products
ship rails whose label exists only in a hover tooltip, and not one of 84 ships
a pointer-aware rail. On a touch device that label is unreachable. → 04
A rail is not exempt from the 44px floor. A ~48px icon-only rail with ~20px
icons sits right on the line; under touch: it must grow, implying roughly a
56px touch rail. No external precedent — this is a house extension.
Anti-patterns
Truncated nav labels ("Dashboa…", "Image Generat…") — a label that ellipses
looks complete and isn't. The entire nav behind a hamburger at desktop width, with
no persistent orientation. A raised + taking the most thumb-reachable point
while destinations are pushed to the corners.
2. List → detail
What the industry does
- Two panes, list on the left, scrolling independently — universal.
- The list column is ~26–30% in two panes, compressing to ~18–19% when a third pane appears. The list is the pane that yields; the detail pane never shrinks first.
- When nothing is selected, the better products let the list expand into the full width rather than rendering an empty pane. The alternative — an explicit "Select a table" placeholder — is defensible but worse on narrow desktops.
Phone: push or sheet, decided by content weight
Both are conventions in their own lane, and the split is clean:
If the detail is a set of labelled attributes the user will skim and dismiss → sheet. If it has its own scroll depth, sub-navigation, or children → push.
- Sheet (eight products): 40–90% height over a dimmed parent. The parent staying visible is the point — these are inspections, and you inspect several in a row.
- Push (six products): full screen, back affordance, list state preserved.
- Sheets stack on top of pushes for secondary action lists. The detail is a push; its actions are a sheet.
What we do
The split pane is page layout, so it keys to the container the screen owns — not the viewport, because our screens render inside preview frames:
// docs/app/theme-creator/flows/flow-shell.tsx:170
// `@container/screen` — every screen measures the frame, not the window.The finapp activity list switches push → pane at a measured 42rem, read from
a ResizeObserver on the list root. Measured rather than fixed because the
pane's presence does not change the list root's width — no feedback loop. → 07
One trap, learned the hard way: the list root is grid at every width and
only its columns change in the wide band. A base flex with a
@2xl/screen:grid override rendered as flex — the base utility won — and the
pane fell under the list. → 04 Part 1
3. Tables on a phone
First: which end of the density spectrum are you on?
The answer splits cleanly, and getting this branch wrong is the main way table guidance goes bad.
Consumer density — identifiers are short and human-readable (a team name, a merchant, a ticker). Compress. See below.
Enterprise density — identifiers are machine identifiers (an ARN, a distribution ID, a UUID). Do not compress — scroll, with the identity column stuck. Abbreviating an ARN destroys the only thing that makes the row identifiable.
The reference for the second case is an enterprise console system, and it is emphatic. Measured live at 388px against 881px, its flagship table is byte-for-byte the desktop table in a scroll port: identical column widths (40/180/120/160/100/100/100), identical header strings ("Distribution ID", not "ID"), identical 39px row height, identical 30 rows per page. No compression, no stacking, no column dropping, no density change.
Its substitute for compression is four things together: **sticky identity column
- sticky actions column + horizontal scroll + a user-controlled column menu.**
Worth knowing that even they haven't squared this. Their own responsive guidance says "Don't make the user scroll horizontally, or zoom out" — and its flagship dense component does exactly that. The unstated reconciliation is that the page never scrolls horizontally; only the table's scroll port does, and it carries a permanently visible scrollbar.
Consumer density: compress, don't stack
Ranked by observed frequency at 375–393pt:
- Compress the real table — by a wide margin. Single-character headers, right-aligned 1–3 digit numerals, and a 3-letter identity code with a logo instead of a full name. A genuine 7-column table survives 375px with no horizontal scroll at all; others in the sample fit 6, 5 and 4.
- A column-set switcher — a
Short | Full | Formtoggle. The user chooses density and nothing is silently dropped. - A summary excerpt with a link out — one league app shows its own division and "See All ›".
- Horizontal scroll with a frozen identity column.
- Card stack — only for 1–2 metrics, never as a fallback for a dense table. Every card-stack example in the sample carried at most a value and a delta.
Card-stacking a dense table is not the industry default. It is what you do when there were only two columns to begin with.
Column priority, consistent across every table examined
- Identity never hides — always leftmost, always present.
- The outcome/summary column is retained next, often promoted beside the
identity (one league table orders it
# | Club | PTS | PPG | GP | W | L). - Breakdown columns go first (W/D/L, per-round splits).
- Metadata goes early on web — "Created by", "Last modified".
On the web, column visibility is a user menu, not a breakpoint
Six products, unanimously. Go further and persist it: store all the table preferences, including column widths, when the user leaves the page and restore them when the user comes back. Pair it with a pinned left gutter (24–40px) that survives horizontal scroll.
Make horizontal scroll discoverable rather than forbidden
If the table must scroll, the scroll must be obvious:
- Suppress the native scrollbar (
overflow-x: auto; scrollbar-width: none) and render your ownposition: sticky, 15px scrollbar pinned to the bottom of the scroll port, so it is reachable without scrolling vertically first. - Z-order matters: scrollbar above the sticky columns, below the sticky header (798 / 799 / 800 in the reference implementation).
- Offset it so it never overlaps interactive content — the source comment on the offset variant: "We move the scrollbar lower, so it doesn't overlap interactive elements (such as checkboxes or links)."
⚠ But a scrollable table is not keyboard-complete
This is the most important caveat on the page, and it qualifies everything above.
A horizontally scrolling table region does not scroll with left/right arrow keys in JAWS or NVDA. The off-screen cells are read aloud, but the user cannot follow the focus indicator without a mouse.
This is not speculation. The government design system that recommends the scrollable pattern publishes the failure against its own component, marks the relevant success criterion "passed with exceptions", and has carried an open tracking issue since March 2024. They ship it anyway, and say so — which is the honest posture, but it means the pattern is a visual answer with a known assistive-technology hole.
If you ship it:
- Give the scroll port
tabindex="0"and an accessible name —role="region"witharia-label. The widely copied recipe includes only thetabindexhalf, which delivers focusability without announcing what was focused. - Provide a non-scroll path to the same data: a detail view, an expandable row, or the column-visibility menu.
- Test it in a real screen reader and record the result. Focusable is not operable, and no automated gate we run will catch the difference.
Change presentation across a breakpoint, never semantics
Two government systems take opposite approaches here, and the contrast is the clearest teaching case in the catalogue.
The good pattern: start from a structure that is already semantically
correct on a phone — a description list — and switch only its display at the
wide breakpoint. The element's role never changes, so nothing about the
screen-reader experience varies with viewport width, and there is nothing to
duplicate.
The pattern that costs: start from a <table> and break it —
thead { display: none }, td { display: block }. That strips table-cell
semantics and removes the real header row from the accessibility tree, which
is precisely why every cell then needs a duplicated data-label. The
duplication is not a nicety in that design, it is load-bearing — and the system
shipping it carries a changelog entry for a VoiceOver-in-Safari bug it caused.
The rule: if a breakpoint changes what an element is to assistive technology, you have built two components and you are testing one.
Reducing the data is a legitimate answer
The most accessibility-rigorous system surveyed ships no responsive table at
all. Its entire responsive table behaviour is one optional font-size reduction
(19px → 16px below its tablet breakpoint), and overflow-x appears zero times in
its whole stylesheet. The prescription instead:
"If possible, you should aim to have less data in your tables. If you have a lot of data, try to organise it into multiple tables or multiple pages."
And separately: "Never use the table component to lay out content on a page."
If a table needs a scroll port, a sticky column and a column menu to survive a phone, ask first whether it should be two tables or two pages. Engineers rarely consider that branch, and it is often the right one.
Sticky columns: two hard caps and a floor
- At most 2 leading, 1 trailing. The choices are
None / First column / First two columnsandNone / Last column, nothing more. - Never more than 70% of the content area — past that, the sticky columns crowd out the scrollable content they exist to anchor.
- Deactivate below 148px of remaining scrollable space — the space left beside the sum of the sticky columns' widths, about a third of the smallest breakpoint.
Note what that floor is keyed to: available space, not viewport width. That
is the container axis, applied to a feature rather than a layout. → 07
The two rules we take from the failures
Every hidden column needs a path back. One league table clips its rightmost column flush at the viewport edge — no fade, no scroll cue, no toggle. The data is gone. That is F102.
Two major libraries ship this failure as their documented API — one drops
columns below a breakpoint, and its official demo hides two columns with no
expandable row, no detail drawer, no disclosure of any kind (on a phone that
data is unreachable); the other documents a zero-span cell → display: none as
the idiomatic way to hide a cell at a breakpoint. Both conflict with our reading
of F102.
If we ever ship a responsive-column API, the disclosure partner is not optional. That is the whole difference between relocating content and deleting it.
The most widely deployed enterprise React system's answer to "wide table on a phone" is: don't — render a list. Its mobile sibling ships no table component at all. That is a real position, and it is the opposite of the consumer-app convention above. Decide which case you are in before picking.
Truncation without recovery is the same failure in miniature. One finance app
renders "SEMICONDUCT…" and "FIDELITY CAPIT…"; two funds sharing a prefix are
indistinguishable in the list.
The convention we reject
Interactive rows at 33–38pt — measured in the three densest, best-regarded tables in the set. The industry buys column density with tap-target height. That is exactly the trade the 44px floor forbids.
Do this instead: keep the visual pitch dense and extend the hit area with a
touch:before: pad, or move navigation onto an explicit 44px disclosure control.
→ 04 Part 3
4. Forms
Field layout
Single column on phone is absolute — zero exceptions in 80 screens, including every first/last name pair, which is the pairing most likely to be tempted into two columns.
On desktop, pairing is the minority. Most desktop forms stay single-column at a capped measure of ~400–600px inside a 1280px+ page. Where pairing happens it is strictly semantic pairs; textareas never pair; nothing exceeds two columns.
Labels stay top-aligned at every form factor. Inline-left survives in three products and only on dense settings rows where the left column carries a label plus a helper description. Treat that as a separate layout primitive, not a responsive variant of the form field.
Field's own responsive behaviour
Field's start-label layout collapses below sm — the label stacks above the
control — and from sm up the label column returns with its height ladder,
written as non-overlapping bands (fieldStartLabelHeightMap).
Multi-step
Desktop: a vertical numbered stepper in a left rail with full labels (6 products).
Phone: we reject the convention. The industry drops the labelled step list — to an unlabelled hairline (two products) or to nothing at all (eight products). "Which step am I on, of how many, and what remains" is meaningful content with no path back to it.
Take the one form in the corpus that got it right instead: print
"Step 2 of 2" as text alongside the graphic. That is the compact
representation that survives a narrow width without losing the information —
and it is the pattern WCAG's reachability test actually rewards. → 02 §11
5. Action bars
Phone: full-width, sticky, bottom
The strongest convention in the sweep (13+ products), ~16–20pt side gutter, and no observed case of a button sitting under the home indicator. Two real minority conventions exist: an auto-width bottom-right pill with a back arrow, and a circular FAB.
Desktop: auto-width, right-aligned in a footer. Full-width survives on desktop only inside narrow dialogs.
The useful generalisation: the button goes full-width when its container is narrow, not when the viewport is. That is a container rule, not a breakpoint rule. →
03
The keyboard state
Twelve products dock the sticky bar to the top edge of the keyboard, not the
bottom of the viewport. While the keyboard is up it covers the home indicator,
so safe-area-inset-bottom is the wrong inset.
Keyboard-up is a third layout state, not a variation — mechanics in 02 §7.
And give the bar matching scroll padding: one surveyed product clips "Date of birth" mid-glyph behind its own docked Continue bar.
Destructive actions, and a rule that is easy to miss
| Desktop | Phone | |
|---|---|---|
| Gate | Typed confirmation, 10/10 products | None — single tap, 0/10 |
| Order (side-by-side) | safe left, destructive right | safe left, destructive right |
| Order (stacked) | — | destructive on TOP, safe below |
flex-col md:flex-row silently reverses which button reads as primary. A
single DOM order cannot satisfy both arrangements. It is also an emission-order
violation. Write disjoint bands and set the order explicitly per band.
Always co-locate an escape: one surveyed product ships a full-width red confirm whose only cancel is the corner X.
Never ship a disabled primary with no stated reason — seven products do, it was the most repeated failure in the corpus, and disabled fills reliably fail contrast.
6. Overlays
Below md, a dialog becomes a sheet — but not unconditionally:
- A short confirm may stay centred (2 of 10 phone confirms did).
- A long form presents as a tall sheet with the parent peeking, not an opaque takeover.
- Dismissible sheets carry a grabber (~36×5pt pill, centred).
- Dismiss placement has no consensus — top-left and top-right are evenly split. Do not claim a convention here; pick one and be consistent.
A sheet keys to the viewport, never a container — it escapes its box to cover
the screen (§9, TES-392). → 03
In the theme-creator specifically, portalled overlays are rejected outright: an
AlertDialog portals to <body> and would cover the editor, not the phone.
Confirmation replaces the content in place.
7. Reading measure
A reading column caps at ~max-w-3xl regardless of available width. Desktop
forms cap at ~400–600px — at very large viewports, text lines become too long.
This is a content constraint, not a breakpoint — it applies at every width above the cap.
8. Empty, loading and error states
- Keep the table header row in the empty state; centre the illustration and CTA in the body (6 products).
- Virgin-empty and filtered-empty are two different states with two different strings. "Add your first Note" is not "No results found — try changing your filters." Only two of six products got this right.
- Reconcile your counts. One surveyed product prints "No forms found" above a footer reading "1-2 of 2".
- A split pane has two empty states, not one — one per pane.
- Loading is pane-scoped, not page-scoped: real content in the list, skeleton in the detail.
- Skeleton placeholders shrink, never spill — cap every bar at
max-w-full. FileUpload shipped a skeleton 75px over at 375px behind a parityskipnobody re-read.
Error states per form factor are genuinely unevidenced in this research round. The nearest observation is per-row inline degradation. Do not infer a convention here; it needs its own round.
Known gaps in this catalogue
- The 768–1024 tablet band has no consensus to adopt. The screenshot corpus
cannot see it at all (no tablet tier), so it was measured directly from
production CSSOM instead — and the three best-engineered sites disagree
completely: one makes 768 its single biggest switch (268 rules; a second
column, an
aside, and navs 4 → 7 all arrive there, then hold to 1280), one calls 769 "desktop" and puts its dominant breakpoint at 641, and one goes 640 → 1120 with nothing between. They agree only that something changes between 640 and 769. Treat any tablet rule here as a house decision. - Hover-only affordances cannot be assessed from static captures. Neither confirmed nor ruled out.
- Error states, as above.