Pointer, touch and emission order
The touch:/no-touch: variants, the 44px floor keyed to the pointer, tap-pad arithmetic, and the Tailwind emission-order rules that decide whether any responsive class renders at all.
Expands
CONVENTIONS.md§5 (lines 750–1044). That section is the authority; this file is its long-form explanation with the reasoning kept in.
Two topics live together here because they fail together: the 44px touch floor is the rule people most often write, and emission order is the reason their version of it silently does nothing.
Part 1 — Emission order, or why your responsive class isn't working
Tailwind compiles a variant like sm: into a media query. It does not
compile it into a higher specificity. A bare grid-cols-1 and a
sm:grid-cols-2 land in the same cascade layer at the same specificity, so the
winner is whichever the stylesheet emits last — and Tailwind guarantees nothing
about that order.
Rule 1 — never pair a bare utility with a variant of the same property
✗ grid-cols-1 sm:grid-cols-2 // one column at EVERY width
✓ max-sm:grid-cols-1 sm:grid-cols-2The bare utility is emitted after the sm: stanza, wins everywhere, and the grid
is never responsive. It looks right in the class string, it looks right in the
DOM, and it is wrong in the computed style.
The max-width direction fails identically:
✗ size-5 max-md:size-11
✗ h-9 max-md:h-11
✓ max-md:size-11 md:size-5This exact shape silently killed the touch floors in Stepper, OtpInput, NumberField, SplitButton and Carousel, while the identical pattern kept working elsewhere — because "elsewhere" got lucky with emission order.
Do not fix it by deleting the bare class alone. A display: grid element
with no grid-template-columns gets an implicit auto column, not
repeat(1, minmax(0,1fr)), so long unbreakable content overflows instead of
being clamped. You need the max-sm: band, not an absence.
Rule 2 — a min-width ladder needs upper bounds on every rung
Variant-against-variant is not safe either. Container's default padding shipped as:
✗ px-4 md:px-6 lg:px-8 xl:px-12and measured 24px at a 1400px viewport, where xl:px-12 asks for 48. The
emitted stylesheet explains it: .md\:px-6 appears twice — once at byte
445818, in ascending order beside .lg\:px-8 (451915) and .xl\:px-12 (454034),
and again at byte 617924. The late duplicate wins, so md:px-6 beat both rungs
above it.
Nothing about a min-width variant guarantees it is emitted once, or in ascending order. Write every rung with its own ceiling:
✓ max-md:px-4 md:max-lg:px-6 lg:max-xl:px-8 xl:px-12A ladder of N steps needs N disjoint bands — not one bare utility plus N−1 variants, and not N variants that happen to be ordered correctly today.
Rule 3 — what "disjoint" actually means
A pair is disjoint when no device state matches both halves, and the state is
(width, pointer), not width alone.
| Pair | Disjoint on | Safe? |
|---|---|---|
max-md: / md: | width | ✅ |
md:max-lg: / lg:max-xl: | width | ✅ |
touch: / no-touch: | width and pointer | ✅ |
touch: / md: | nothing — a coarse-pointer tablet at 800px matches both | ❌ |
Mixing the two axes reintroduces the race wearing a costume.
Rule 4 — cross-property pairing is fine
✓ h-8 touch:min-h-[44px]min-height beats height regardless of emission order, because they are
different properties resolved at different stages. Where a floor can be
expressed as a minimum, this is the preferred shape — it needs no no-touch:
twin at all.
Rule 5 — to override a variant pair, you must be a pair
tailwind-merge keys bg-x and dark:bg-x separately. A later bare bg-y
removes only the bare half; the dark: half survives and wins in dark mode at
equal specificity.
A cva base of bg-background dark:bg-muted leaked a flat grey panel through
file-upload's on-color wash until that wash was emitted twice — bare and
dark:. The same holds for max-*, @md: and every other variant prefix.
This is invisible to class-string assertions: both classes genuinely are in the DOM and the unit test passes. Verify by computed value, per theme.
Rule 6 — shorthand and side utilities are different merge groups
✗ px-3 // in a cva whose variants override only one side
✓ ps-3 pe-3px and ps are different tailwind-merge groups, so cn("px-3", "ps-9") keeps
both classes and emission order picks the winner. In dropdown-menu, px-3
won, so the radio indicator — absolutely positioned at start-* — painted on top
of its own label.
The class is in the DOM. The class-string test passes. The computed padding is wrong. Same failure as Rule 1, in logical-property clothing.
Applies equally to py/pt/pb, mx/ms, border-x/border-s, and every
other shorthand-vs-side pair.
Why our bands have no dead zone (and other systems' do)
A disjoint ladder can be written two ways, and only one of them is safe.
The unsafe way — subtract one. One major library's observer matches xs as
max-width: 575px and sm as min-width: 576px. A viewport of 575.5px
matches neither — reachable through browser zoom and fractional-DPI displays —
and every responsive value silently falls back to its default. Its own sidebar
component avoids this with .98 maxima, so the library carries two conventions
at once.
The safe way — negate. Tailwind v4 compiles max-md: to the exact logical
complement of md:. Verified in our own compiled stylesheet:
@media (min-width: 48rem) { /* md: */ }
@media not all and (min-width: 48rem) { /* max-md: */ }not all and (min-width: 48rem) is true for every width the positive form is
false for, fractions included. The pair partitions the axis by construction, so
our bands cannot develop that gap.
Worth knowing: both conventions appear in our shipped bundle from third-party CSS —
(max-width: 767.98px)and(max-width: 767px)are both present alongside(min-width: 768px). The767/768pair has a dead zone at 767.5. It is not ours, but it is in the same stylesheet.
The same compiled output confirms the touch: variants parsed correctly in block
form — @media not all and (min-width: 48rem), (pointer: coarse) and its exact
complement — which is the check §5 warns you to perform rather than assume.
"But the big libraries use open-ended bands"
You will hear this, so here is the answer. Three major peer systems all use
open-ended ascending min-width ladders with no upper bounds. Three to zero
against us — and they are not counter-evidence, because their architecture is
different.
In all three, the responsive CSS is produced by one central function that owns the whole ladder, emitting every rung from a single ordered source. A consumer cannot interleave a competing rule, so order is guaranteed by ownership.
We have the opposite shape: our primitives emit utilities into a shared stylesheet, alongside utilities the consumer authored, with emission order decided by which source file Tailwind scanned them from. Nobody owns the order. Disjoint bands remove the ambiguity instead of trying to control it.
Two legitimate alternatives exist, and we use neither today:
- Cascade layers. One peer system ships its own named layer precisely to
make precedence declared rather than emergent, and documents the Tailwind v4
interop (its layer declared between
baseandcomponents). Tailwind v4 already declares its own layers, so a named Tessina layer is available to us and would address the same problem without doubling every rung. - Runtime sheet sorting — a CSS-in-JS engine's approach. Not portable to a build-time utility pipeline, but proof that "guarantee the order" is a real peer to "remove the ambiguity", not a workaround.
The gate
scripts/emission-order/scan.ts gates Rules 1–3 in test:ci at a baseline of
zero. Re-baseline with pnpm emission:baseline. The docs repo has its own
port (pnpm emission:check) because the ui scanner walks ui/src only and its
variant regex cannot see named containers like @2xl/screen.
The blind-spot that matters: the scanner only checks variants listed in its
ZERO_SPECIFICITY_VARIANTset. A variant missing from that set makes the gate report a clean zero by being blind to it, not by the library being correct. Adding a new custom variant means adding it to that set in the same change.
Part 2 — The 44px floor is keyed to the pointer
First, the floor that is NOT keyed to anything
Two WCAG criteria govern target size and they are different criteria, not versions of one:
| 2.5.5 Target Size (Enhanced) | 2.5.8 Target Size (Minimum) | |
|---|---|---|
| Level | AAA | AA |
| Size | 44 × 44 CSS px | 24 × 24 CSS px |
| Spacing exception | none | yes — a 24px-diameter circle centred on each target must not intersect another |
The 24px AA floor is unconditional. WCAG defines pointer input as "a mouse,
pen, or touch contact" — it is not touch-specific. So a no-touch: branch that
drops a control below 24 × 24 fails AA for mouse users, and pointer-keying
does not excuse it.
Pointer-keying governs the 44px enhancement only. Read the rest of this part with that split in mind: 24 is a floor we owe everyone, 44 is what we add when a finger might be the pointer.
Both criteria share an Inline exception, and 2.5.5's is the broader of the two — "in a sentence or block of text" against 2.5.8's "constrained by the line-height of non-target text". Inline text links are exempt from the 44px floor on that basis (§9, TES-40).
Why the viewport version was wrong
The floor shipped for months as max-md:min-h-[44px] — "viewport under 768px",
used as a proxy for "phone". It is wrong at exactly the edge that matters.
An iPad Mini is 768px wide and reports pointer: coarse / hover: none. It
is unambiguously a touch device, one pixel outside the band, floor switched off.
Measured on real WebKit at Playwright's devices["iPad Mini"] metrics, against
components containing no tablet-specific code at all:
| Example | Interactive targets under 44px |
|---|---|
toolbar-demo | 8 of 8, at 36×36 |
pagination-demo | 8 of 8, at 40×40 |
stack-media-object | Button computed 40px tall |
Every phone preset passed. The gap was tablets — all of them.
The two variants
/* ui/src/styles/globals.css:51 */
@custom-variant touch { @media (width < 48rem), (pointer: coarse) { @slot; } }
@custom-variant no-touch { @media (width >= 48rem) and (pointer: fine),
(width >= 48rem) and (pointer: none) { @slot; } }They are an exact partition, and that is the whole point. pointer resolves
to exactly one of none / coarse / fine, so every (width, pointer) state
matches exactly one variant — no overlap for emission order to decide, and no
hole where an element gets neither rule. An approximate complement would
reintroduce the Rule 1 race.
Consequences:
touch:pairs only withno-touch:. Never withmd:, never with a bare utility of the same property.- A touchscreen laptop driven by a mouse keeps desktop density. It reports
pointer: fine, so it isno-touch:. Usingany-pointer: coarsewould have caught it — and inflated every touch-capable laptop, which is most of them. This was an owner decision on 2026-09-10, not an oversight. - Write the variants in BLOCK form. The shorthand
@custom-variant touch (@media (width < 48rem), (pointer: coarse));compiles in Tailwind 4.1.18 to a bare(pointer: coarse) { … }block — the comma splits into sibling at-rules and every branch after the first loses its@media, producing invalid CSS that silently does nothing. Read the compiled stylesheet before trusting a custom variant. A class appearing in the DOM proves nothing about whether its rule parsed.
What stays on max-md:
max-md: is still correct for everything that genuinely responds to width:
max-md:grid-cols-1, max-md:gap-3, max-md:rounded-none, a phone's
reading-copy lift. Only the floors moved. A narrow desktop column is not a
phone.
Three categories moved with the floor because they are the touch affordance:
- Reflow that creates the target —
number-field's stacked stepper re-flowing into a side-by-side 44px pair. - Gaps whose only job is to space
::beforetap pads —search's trailing actions,color-picker's swatch tiles,chat-bubble's action and reaction rows,prompt-input's toolbar gap. Keyed to width, an iPad Mini got 44px pads with the desktop gap, so adjacent pads overlapped and the later sibling won the strip — measured at 36, 38, 30, 36, 32 and 40px of 44 by hit test. - Gaps that stand down because of the floor — the toggle-row rhythm
(
FieldsetBody's rows-only map, verticalCheckboxGroup/RadioGroupitems) istouch:gap-0 no-touch:gap-N. Keyed to width, the same iPad Mini kept the 44px row floor and got the desktop gap back: 56px of row pitch atmdwhere a phone reads 44. The floor counted twice.
The 16px type floor moved too
iOS Safari zooms the page when a focused text control renders under 16px — and iPads and landscape iPhones are 768px or wider, so keyed to width they got the 12–14px desktop half and zoomed.
fieldTextSizeMap is touch:text-base no-touch:text-*, and so are the
hand-written floors beside it: pagination's jump input,
top-header-desktop's search input, calendar's date-time wells and its caption
and time-zone selects, and prompt-input's composer. sidebar's search input
takes a lone touch:text-base and otherwise inherits its well's type.
If a user can type into it, it is ≥16px on touch. No exceptions.
Part 3 — Tap-pad arithmetic
Where the floor is an invisible ::before, the pad grows out of the host's
padding box — so a border is inside the thing you are measuring from.
Chip shipped exactly that failure at every size: an h-8 chip is 32px with a 1px
border, leaving a 30px containing block, and touch:before:-inset-y-[6px]
computed 30 + 12 = 42, not 44.
Measured on real WebKit, 2026-09-10 — iPhone SE, iPad Mini and iPad Pro 11 identically. Note which axis moves:
| Target | Rect | ::before |
|---|---|---|
chip body (h-8, 1px border) | 97.8 × 32 | 95.8 × 42 |
| chip remove button | 32 × 30 | 44 × 42 |
checkbox (before:size-11) | 20 × 20 | 44 × 44 |
icon-button xs (no border) | 32 × 32 | 44 × 44 |
The shortfall appears only in the axis the border cuts, never in one that already cleared 44. That is why an outset looks exact for years on an unbordered host, and why this went unnoticed across the library.
Two correct spellings
Choose by which direction the pad should grow — not by taste.
A. Named — no arithmetic to get wrong. The default.
touch:before:size-11 // square control, not flush
touch:before:h-11 // full-width band
touch:before:start-0 touch:before:w-11 // flush against an edge44 is 44 whatever the border does, and one string covers every size. Used by
icon-button, avatar's action badge, command, search, textarea,
code-block.
B. Outset, with the border counted. Use when the pad must not spill.
Chip does this: each vertical inset is (46 − height) / 2, and the remove
button's pad grows inward over the label by 44 − width. Necessary because a
symmetric pad on a control flush with its container's edge hangs outside it and
adds real horizontal page scroll on a phone (TES-136).
What is never correct is an outset with the border left out of the arithmetic.
The docs-side spelling is gated by docs/lib/touch-floor-examples.test.ts.
If a control must look smaller, shrink the VISIBLE box, not the target
Worth knowing before you reach for the obvious answer. A small checkbox variant
can render its visible box at 24px while the invisible <input> stays
44 × 44, shifted into the leading margin so the visible edge still aligns
with the text beside it.
Two details make it work:
- The input sits above the label (
z-index: 1), so touch exploration and automation tools resolve the control rather than hitting the label first. - The 44 is composed, not asserted — a 40px visible box plus a 4px gutter.
Never buy a smaller appearance with a smaller hit area. The paint and the target are separate concerns, and this is the shape that keeps them separate.
Part 4 — The drag rule
Anything operated by dragging ships a single-pointer alternative (WCAG 2.5.7) — step buttons or an equivalent tap/click control.
Arrow keys satisfy 2.1.1, not 2.5.7: a touch-only user has no keyboard. This is the distinction people get wrong.
Reference implementations: the resizable SidebarRail's widen/narrow steppers (TES-299) and the Textarea grip's shrink/grow steppers (TES-300).
Known open gap
22 of 52 interactive components declare no 44px floor of their own. That
number overstates the gap — several legitimately inherit one from a composed
primitive (alert renders IconButton, which has it) — so it needs confirming
per component rather than treating as 22 defects.
breadcrumb was one of them and is now closed (TES-202), and it teaches the
general lesson: a floor on the row is not a floor on the control. The <li>
was 44px tall while BreadcrumbLink was a 20px inline <a>, so a thumb landing
off the text hit nothing.
Where a component hands the press to a child, confirm the child carries the pad.
Next: 03-container-queries.md for the other adaptation axis, or
07-decision-guide.md to choose between them.