Touch Target
Raise a small control to a 44px hit area on touch devices, without changing how it looks.
Visible box: 24px. Hit area on a coarse pointer: 44px.
Ships with the spark styles (@epilot/spark-ui/styles), so there is no install
step. Both classes self-gate on @media (any-pointer: coarse): they do nothing
on a mouse, so desktop density is untouched and you never have to write the
media query yourself.
44px (2.75rem) is WCAG 2.5.5 Target Size (Enhanced), level AAA. The AA floor,
2.5.8 Target Size (Minimum), is 24px, so a control that already measures 24px
passes AA and still fails the target this utility sets.
Usage
| Class | Effect |
|---|---|
touch-target | centered ::after overlay sized max(100%, 2.75rem); visible box unchanged |
touch-target-row | min-block-size: 2.75rem; the box itself grows |
<button
aria-label="Dismiss"
className="touch-target size-6 rounded-md border-2"
/>Pick by whether the control has room around it:
- Isolated control (an icon button, a checkbox, a switch, a chart legend
toggle): use
touch-target. The overlay reaches outside the visible box, so the control keeps its size and the space around it absorbs the extra hit area. - Tiled rows (menu items, list rows, anything stacked with no gap): use
touch-target-row. An overlay would reach into the neighboring row, and since every neighbor is itself a target, that is a mis-tap rather than dead space.
touch-target never shrinks anything: max(100%, 2.75rem) keeps the overlay at
the control's own size once that is already 44px or larger. Applying it to a
control that is big enough is a no-op, not a regression.
The 44px is baked into both utilities and is not tokenised, so there is no knob
for a different floor. For anything else, size the control directly (h-*,
padding) or use hit-area-* with the expansion you want.
Button already carries touch-target on its base, and so do the components
built from it, so you rarely apply it to a Button yourself. Reach for these
classes on custom controls and on bare elements (<button>, <a>, a row
<div> with a role).
For closing the dead zone between two controls that are already big enough,
use hit-area instead. It expands a box by a chosen
amount on every pointer type, which is a different job from meeting a minimum
on touch.
Examples
Tiled rows
Why stacked rows grow instead of taking an overlay. Blue marks what a coarse pointer claims in each case: overlapping rings on the left, one band per row on the right.
touch-target: the 44px claims overlap
touch-target-row: each row is 44px
Notes
touch-target-rowcan override a larger height. It is a plainmin-block-sizeemitted late in the utilities layer, so on a coarse pointer a row givenmin-h-16measures 44px, not 64px.min-h-*is the only thing affected: a fixedh-16, or padding, still wins, becausemin-block-sizeonly raises a height and never clamps one down. Useh-*or padding on a row that must be taller than the floor.- The overlay measures the padding box. Percentages in
max(100%, 2.75rem)resolve against the padding box, so on a bordered control the overlay reads a little under the border box (a 48px underline tab measures 46px). It stays above the floor either way, because anything under 44px takes the2.75rembranch. - The overlay inherits
pointer-events. A control markedpointer-events-none(a decorative checkbox inside a clickable row, for example) gets an inert overlay, so the row keeps handling the press. That is deliberate: the utility never steals events from an ancestor that owns them. - Reserve the overlay as layout inside a scroller. The overlay is absolutely
positioned, so it can add scrollable overflow at the edge of a scroll
container.
switch,checkboxandradioreserve it with a margin instead, and the margin is derived from--spacingrather than picked from the spacing scale. - It does not carry the AA floor on a mouse. The overlay only exists under
any-pointer: coarse, so a control sized off the spacing scale still shrinks with[data-scaling]for a mouse user:size-6measures 21.6px at 90%, under the 24px 2.5.8 AA minimum.checkbox,radioandswitchpair the utility with an always-onbefore:size-10for exactly this reason. On a custom control that lands near 24px, either size it in fixed units or add ahit-area-*. - The 44px is fixed, but the spacing scale is not.
[data-scaling]scales--spacing, so agap-2between two floored controls shrinks while their overlays stay 44px. When you need clearance between two touch targets, use a fixed value (gap-[0.75rem]) or derive it from--spacing, or the clearance you measured at 100% quietly disappears at 90%. - Verify in a browser, not in a unit test. jsdom reports no geometry, so a test can only assert that the class is present. Measure the rendered box under coarse-pointer emulation.