# Touch Input

> Use when the pointer is a finger: hit-target size, thumb reach, hover gating on touch, tap latency, scroll conflicts, and iOS Safari input quirks.

- Skill: `agentsorg/touch-input` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add agentsorg/touch-input`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/touch-input/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/touch-input

---



# Designing for the Thumb

Assume the pointer is coarse, imprecise, attached to a hand that covers part of the screen, and incapable of hovering. That single assumption decides most of what follows: hit areas grow past their visuals, hover reveals but never enables, feedback arrives on press rather than on release, and every gesture has to declare which axis it wants before the browser has to guess. Design the touch version as the real one and let the fine-pointer version be the variant — the reverse produces interfaces that are technically usable on a phone and miserable in practice. **`gestures` owns everything that happens once the finger starts moving — velocity, springs, rubber-banding, mid-flight interruption; this skill stops at the moment of contact: size, placement, latency, and which axis wins.** Keyboard order, focus and ARIA are `a11y`; whether the app reads as installed is `native-feel`.

**Work in the project's existing responsive and styling system.** If it has a `hover:` variant strategy, a `size-*` scale, or a touch-target utility, extend that rather than adding raw media queries beside it. Check whether an interaction library (Vaul, Embla, use-gesture, Radix) already owns `touch-action` and pointer capture on the component you are touching — two things setting `touch-action` on the same element is a bug that only reproduces on device. If the project targets desktop only today, add capability queries anyway; they cost nothing and a touchscreen laptop is already in the wild.

| Topic | Reference |
|---|---|
| A bug that only reproduces on an iPhone | Open `references/platform-quirks.md` when something works everywhere except iOS Safari — input zoom, video autoplay, viewport height, tap highlight, scroll locking. |

## Core Principles

1. **The hit area is `44×44`; the visual can be anything.** Apple's HIG sets `44×44pt` as the minimum comfortable target, and a 24px icon button with a 24px hit area is the single most common touch defect. Keep the icon at `24px` and expand the target with `min-width: 44px; min-height: 44px`, or a pseudo-element at `inset: -10px`. *Exception:* a target inside a run of inline text cannot be 44px tall without wrecking the line — WCAG 2.2 SC 2.5.8 puts the hard floor at `24×24` CSS px and demands clear spacing whenever you sit under 44.

2. **Adjacent targets need dead space between them.** The finger's contact patch is far wider than the single point the browser reports, so two hit areas that touch will fire the wrong one at the edges. Leave at least `8px` of untargeted space between independent controls (house default), and check that *expanded* hit areas do not overlap — a delete whose invisible padding reaches under a confirm is a data-loss bug that no screenshot shows. *Exception:* segmented controls, paginators and calendar grids, where the options are adjacent by design and none is destructive.

3. **Gate hover on capability, never on width.** Touch devices fire `:hover` on tap and leave the state stuck until the next tap elsewhere. Wrap every hover style in `@media (hover: hover) and (pointer: fine)` (Emil Kowalski). A viewport-width breakpoint is the wrong test: a touchscreen laptop is wide *and* coarse, and a phone in a desktop-mode browser is neither. *Exception:* a purely decorative cursor-follow effect may gate on `(pointer: fine)` alone, since it has no state to get stuck in.

4. **Nothing may be reachable only by hover.** Hover reveals and embellishes; it must never be the sole route to an action. A row's delete button that appears on hover is invisible on touch, and "long-press to discover" is not a discoverable affordance. Give coarse pointers a persistent control — usually a visible overflow button. *Exception:* a hover shortcut that duplicates an action already present in an open menu or toolbar.

5. **Respond on `pointerdown`, commit on `pointerup`.** Waiting for `click` to show the pressed state reads as lag, because the finger is already lifting by then. Set the press state on `pointerdown`, run the action on `pointerup` inside the target, and cancel if the finger leaves — with roughly `10px` of slop so a shaky release still commits. *Exception:* destructive actions commit on release only, never on press, and must stay cancellable by dragging away.

6. **Remove residual tap latency with `touch-action`, not with a library.** The historical 300ms double-tap-zoom delay is gone on any page with `<meta name="viewport" content="width=device-width">`, but an element that is still double-tap-zoomable pays it. Set `touch-action: manipulation` on buttons, links and inputs; `touch-action: none` on a surface that implements its own pan or zoom. *Exception:* never put `touch-action: none` on a scroll container — you delete scrolling, and it only shows up on device.

7. **Declare the scroll axis before the gesture starts.** A horizontally swiping row inside a vertical scroller forces the browser to wait for JS to decide who owns the gesture, and the first frames are lost. Declare it in CSS: `touch-action: pan-y` on children of a vertical scroller that also drag sideways, `pan-x` on a horizontal carousel inside a scrolling page. Claim the axis on the first move and hold it for the life of the gesture — an arbitration that flips mid-drag feels broken. *Exception:* a full-screen canvas that genuinely owns both axes takes `none`. Velocity thresholds and what happens after the axis is claimed are `gestures`.

8. **Contain scroll at panel boundaries.** Scrolling to the end of a modal, drawer or dropdown must not start scrolling the page behind it — on iOS the page keeps its scroll position and the user loses their place. Put `overscroll-behavior: contain` on every independently scrolling panel. *Exception:* `overscroll-behavior-y: none` on the document root also kills pull-to-refresh — correct for an app-like surface, hostile on a content page.

9. **Put the primary action where the thumb already is.** On a large phone held one-handed, the comfortable arc is the bottom third on the holding side; the diagonally opposite top corner is the worst reach on the device. Anchor primary actions to the bottom or make them full-width, and deliberately place destructive actions outside the natural arc so the reach itself acts as a confirmation. *Exception:* on desktop and tablet a bottom-anchored primary action reads as an unported mobile layout — anchor it to the form instead. Home-indicator and notch insets are `responsive`.

## Smells and Fixes

| Smell | Fix |
|---|---|
| `.icon-btn { width: 24px; height: 24px }` | Keep the icon 24px, give the button `min-width/min-height: 44px` or `::before { inset: -10px }` |
| Hover styles with no media query | Wrap in `@media (hover: hover) and (pointer: fine)` |
| Hover gated on `@media (min-width: 1024px)` | Gate on capability; width does not imply a fine pointer |
| Row action visible only on hover | Add a persistent overflow control for coarse pointers |
| Pressed state applied in a `click` handler | Apply on `pointerdown`, commit on `pointerup` |
| `touch-action: none` on a scrollable list | `pan-y` if children drag sideways, otherwise remove it |
| Drawer scrolls the page behind at its end | `overscroll-behavior: contain` on the drawer |
| A `fastclick`-style tap library in the deps | Delete it; `touch-action: manipulation` plus a correct viewport meta replaces it |
| Two libraries setting `touch-action` on one node | Pick one owner; the second silently wins in ways that differ per browser |

## Checklist

- [ ] Every target is at least `44×44`, and never under the `24×24` floor
- [ ] At least `8px` of dead space between adjacent independent targets; expanded hit areas do not overlap
- [ ] Every hover style is inside `@media (hover: hover) and (pointer: fine)`
- [ ] No action is reachable only by hover or only by long-press
- [ ] Press feedback fires on `pointerdown`; destructive actions commit only on release
- [ ] `touch-action: manipulation` on buttons, links and inputs
- [ ] Every drag inside a scroller declares its axis with `pan-x` / `pan-y`
- [ ] `overscroll-behavior: contain` on modals, drawers and scrolling panels
- [ ] Primary actions sit in the bottom arc on phones
- [ ] Tested on a real device, not just a resized desktop window

