DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

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

example.tsx
// 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:

    MediumIndicator
    Vertical list (sidebar)filled/tinted full-width row
    Icon railfilled square behind the icon
    Bottom baricon + label tint
    Horizontal stripunderline — 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:

example.tsx
// 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:

  1. 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.
  2. A column-set switcher — a Short | Full | Form toggle. The user chooses density and nothing is silently dropped.
  3. A summary excerpt with a link out — one league app shows its own division and "See All ›".
  4. Horizontal scroll with a frozen identity column.
  5. 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

  1. Identity never hides — always leftmost, always present.
  2. The outcome/summary column is retained next, often promoted beside the identity (one league table orders it # | Club | PTS | PPG | GP | W | L).
  3. Breakdown columns go first (W/D/L, per-round splits).
  4. 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 own position: 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" with aria-label. The widely copied recipe includes only the tabindex half, 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 columns and None / 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

DesktopPhone
GateTyped confirmation, 10/10 productsNone — single tap, 0/10
Order (side-by-side)safe left, destructive rightsafe 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 parity skip nobody 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.
Pointer, touch and emission orderLayout primitives

On this page

1. NavigationWhat the industry doesThe finding that should change your mental modelWhat we doTwo rules that are ours, not theirsAnti-patterns2. List → detailWhat the industry doesPhone: push or sheet, decided by content weightWhat we do3. Tables on a phoneFirst: which end of the density spectrum are you on?Consumer density: compress, don't stackColumn priority, consistent across every table examinedOn the web, column visibility is a user menu, not a breakpointMake horizontal scroll discoverable rather than forbidden⚠ But a scrollable table is not keyboard-completeChange presentation across a breakpoint, never semanticsReducing the data is a legitimate answerSticky columns: two hard caps and a floorThe two rules we take from the failuresThe convention we reject4. FormsField layoutField's own responsive behaviourMulti-step5. Action barsPhone: full-width, sticky, bottomThe keyboard stateDestructive actions, and a rule that is easy to miss6. Overlays7. Reading measure8. Empty, loading and error statesKnown gaps in this catalogue