Choosing the adaptation axis
A decision tree for content vs container vs viewport vs pointer, with the precedents each branch was decided by.
Most responsive bugs in this library were not wrong values. They were the right value on the wrong axis — code that is green in every test and wrong on real hardware.
There are four axes. Pick before you write the class.
The question to ask first
Not "how wide is the screen?" but:
What actually changed, such that this layout should change too?
| What changed | Axis | Spelling |
|---|---|---|
| The page is reorganising | viewport | max-md: / md: |
| The component's own column got narrow | container | @max-md: / @md: |
| The content grew — longer label, translation, text zoom | neither | flex basis, wrapping |
| The input device is a finger | pointer | touch: / no-touch: |
The decision tree
Is it a target size, spacing between targets, or an input's type size?
│
├─ YES → POINTER. `touch:` / `no-touch:`. Stop here.
│ A narrow desktop column is not a phone; a wide tablet is.
│
└─ NO ↓
Does the thing escape its box to cover the screen?
(sheet, drawer, modal, toast, page-level nav)
│
├─ YES → VIEWPORT. It occupies the screen, so the screen is what matters.
│ The container it happens to sit in tells you nothing.
│
└─ NO ↓
Would this component render at a different width than its siblings —
inside a sidebar, a split pane, a card, a docs preview, a consumer's column?
│
├─ YES → CONTAINER. Name the container if consumers might nest one inside.
│
└─ NO ↓
Is the trigger actually the CONTENT — text length, translation, zoom —
rather than any box getting narrower?
│
├─ YES → NEITHER QUERY. Wrap by flex basis.
│ No width query fires for 200% text zoom at a fixed width.
│
└─ NO → VIEWPORT. It is genuine page layout.Case law
Each of these was decided by measurement, not preference. They are the precedents to reason from.
Pointer beat viewport — the iPad Mini
The 44px floor shipped as max-md: — "under 768px", a proxy for "phone". An iPad
Mini is 768px with pointer: coarse: a touch device, one pixel outside the
band, floor off. Measured: toolbar-demo 8 of 8 targets at 36×36.
Every phone preset passed. The gap was tablets, all of them.
→ Floors, tap-pad gaps and input type sizes are pointer-keyed. 04.
Container beat viewport — FileUpload and Alert
FileUpload's zone broke at a 138px width inside a padded stage at a 320px
viewport, and identically at 375 in any narrow column. A viewport max-md:
pair can never fire for that.
Alert's two trailing actions squeezed its title into four wrapped lines inside a
576px docs preview on a 1100px window — while md: cheerfully reported
"desktop".
→ A component in a consumer's column measures its own box. 03.
Content beat both — Alert round 3, and the trigger value
Alert then deleted its container queries. The real trigger was not width at all:
"Once the text wraps to a second line, additional actions move below."
Mechanism: grow shrink basis-80 on the text block in a wrapping row.
TES-72 is the sharper case: a trigger's trailing value stacks by content, and neither a viewport nor a container query fires for Dynamic Type or 200% text zoom at a fixed width. Deleting the meta at narrow widths was considered and rejected as WCAG F102.
→ If the trigger is text, no width query will catch it.
Viewport beat container — the sheet
ArtifactPanel renders two presentations from one component. Container-width
switching was explicitly rejected: a sheet is a viewport-level overlay. It
forks at (max-width: 767px) (§9, TES-392).
→ Anything that covers the screen keys to the screen.
Neither — Container's own gutters
Container's padding follows the viewport on purpose. A container query styles
descendants of the query container, so Container cannot query its own width for
its own padding, and cqi would need the parent to be a size container
(§9, TES-88).
→ An element cannot query itself. This is normative, not a limitation of our implementation.
Fixed threshold or measured?
Both appear in the library. The rule that separates them:
Measure only when the trigger is independent of the outcome.
NavigationMenu's three axes are fixed because each one "frees the very space that triggered it — measuring would oscillate." Its drawer axis is measured, because the root's width is set by its parent rather than by its own contents.
The finapp activity list measures its push-vs-pane threshold (42rem) with a
ResizeObserver for the same reason: the pane's presence does not change the
list root's width.
A feedback loop between trigger and result means a fixed threshold.
Or break the loop with asymmetric thresholds
There is a third option, and it is better than either where it applies. A surveyed split panel flips between a side and a bottom position at 280px of available width — a textbook feedback loop, because the flip changes whether a scrollbar exists, which changes the available width. Its source comment:
"there is a chance that … a scrollbar appears or disappears from the page, which changes the available space again. This could potentially lead to an infinite loop. To prevent that, we give the split panel some 'resistance' to change from bottom to side (but not from side to bottom)."
The implementation is one line of asymmetry: promote at
threshold + scrollbarWidth, demote at threshold. The hysteresis band is
exactly the width of the thing that causes the oscillation.
Reach for this whenever a width-driven flip changes the width.
Prefer "available space" over "viewport" even for features
The axis question is usually asked about layout, but it applies to switching a feature on and off too, and one surveyed system is the clearest example:
| Decision | Keyed to |
|---|---|
| Sticky columns deactivate | 148px of remaining scrollable space |
| Split panel forced to bottom | 280px of available width |
| Content never shrinks below | 280px, every content type — panels shrink first |
None of these reads the viewport. "Protect the content, shrink the chrome" is the principle underneath all three, and it is a container-axis rule wearing different clothes.
Before you commit to an axis, three sanity checks
- Container scale ≠ viewport scale.
@mdis 448px, not 768. Writing@md:expecting 768 is the most common container-query error here. - Two-sided bands, always.
p-4 @md:p-6is broken exactly asgrid-cols-1 sm:grid-cols-2is. Emission order does not care which prefix you used.04Part 1. - Does a JS read need the same axis?
useSafeAreaInsetssubscribes toorientationchangeas well asresize; auto-resize listens to the windowresizeevent as well as a ResizeObserver, "because a breakpoint swaps the type ladder at an unchanged width, which no observer sees" (§9, TES-143).
The two axes we do not have
Named so nobody assumes they are covered:
- No height axis. Only one surveyed system defines one (compact height < 480dp) and warns two-pane layouts are impractical there. A landscape phone at 900×400 passes every width breakpoint we own and is still a bad two-pane target.
- Nothing above 1536. Only one surveyed system specifies behaviour past its top breakpoint. We say nothing about a 2560px monitor.
When two axes both seem right
Split the component. FileUpload is the worked example: layout arrangement on the container axis, type floor on the viewport/pointer axis, in the same file.
// ui/src/components/file-upload/shared.tsx:130 — layout, container axis
md: "@max-md:gap-3 @max-md:py-4 @md:gap-4 @md:py-6"
// ui/src/components/file-upload/shared.tsx:108 — type floor, viewport axis
xs: "max-md:text-sm max-md:leading-5 md:text-xs md:leading-4"That is not inconsistency. Those are two different questions about the same component, and they have two different answers.
Next: 03-container-queries.md, 04-pointer-and-touch.md, or
06-layout-primitives.md for the banded APIs.