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

ClassEffect
touch-targetcentered ::after overlay sized max(100%, 2.75rem); visible box unchanged
touch-target-rowmin-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

Charge now
Pause
Schedule

touch-target-row: each row is 44px

Charge now
Pause
Schedule

Notes

  • touch-target-row can override a larger height. It is a plain min-block-size emitted late in the utilities layer, so on a coarse pointer a row given min-h-16 measures 44px, not 64px. min-h-* is the only thing affected: a fixed h-16, or padding, still wins, because min-block-size only raises a height and never clamps one down. Use h-* 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 the 2.75rem branch.
  • The overlay inherits pointer-events. A control marked pointer-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, checkbox and radio reserve it with a margin instead, and the margin is derived from --spacing rather 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-6 measures 21.6px at 90%, under the 24px 2.5.8 AA minimum. checkbox, radio and switch pair the utility with an always-on before:size-10 for exactly this reason. On a custom control that lands near 24px, either size it in fixed units or add a hit-area-*.
  • The 44px is fixed, but the spacing scale is not. [data-scaling] scales --spacing, so a gap-2 between 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.

On this page