DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
AI agentsContributing

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

example.plaintext
✗ grid-cols-1 sm:grid-cols-2      // one column at EVERY width
✓ max-sm:grid-cols-1 sm:grid-cols-2

The 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:

example.plaintext
✗ size-5 max-md:size-11
✗ h-9   max-md:h-11
✓ max-md:size-11 md:size-5

This 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:

example.plaintext
✗ px-4 md:px-6 lg:px-8 xl:px-12

and 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:

example.plaintext
✓ max-md:px-4 md:max-lg:px-6 lg:max-xl:px-8 xl:px-12

A 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.

PairDisjoint onSafe?
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

example.plaintext
✓ 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

example.plaintext
✗ px-3   // in a cva whose variants override only one side
✓ ps-3 pe-3

px 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:

example.plaintext
@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). The 767/768 pair 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 base and components). 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_VARIANT set. 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)
LevelAAAAA
Size44 × 44 CSS px24 × 24 CSS px
Spacing exceptionnoneyes — 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:

ExampleInteractive targets under 44px
toolbar-demo8 of 8, at 36×36
pagination-demo8 of 8, at 40×40
stack-media-objectButton computed 40px tall

Every phone preset passed. The gap was tablets — all of them.

The two variants

example.css
/* 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 with no-touch:. Never with md:, 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 is no-touch:. Using any-pointer: coarse would 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:

  1. Reflow that creates the target — number-field's stacked stepper re-flowing into a side-by-side 44px pair.
  2. Gaps whose only job is to space ::before tap 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.
  3. Gaps that stand down because of the floor — the toggle-row rhythm (FieldsetBody's rows-only map, vertical CheckboxGroup / RadioGroup items) is touch: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 at md where 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:

TargetRect::before
chip body (h-8, 1px border)97.8 × 3295.8 × 42
chip remove button32 × 3044 × 42
checkbox (before:size-11)20 × 2044 × 44
icon-button xs (no border)32 × 3244 × 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.

example.plaintext
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 edge

44 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.

Container queriesAdaptive patterns

On this page

Part 1 — Emission order, or why your responsive class isn't workingRule 1 — never pair a bare utility with a variant of the same propertyRule 2 — a min-width ladder needs upper bounds on every rungRule 3 — what "disjoint" actually meansRule 4 — cross-property pairing is fineRule 5 — to override a variant pair, you must be a pairRule 6 — shorthand and side utilities are different merge groupsWhy our bands have no dead zone (and other systems' do)"But the big libraries use open-ended bands"The gatePart 2 — The 44px floor is keyed to the pointerFirst, the floor that is NOT keyed to anythingWhy the viewport version was wrongThe two variantsWhat stays on max-md:The 16px type floor moved tooPart 3 — Tap-pad arithmeticTwo correct spellingsIf a control must look smaller, shrink the VISIBLE box, not the targetPart 4 — The drag ruleKnown open gap