useLongPress
A press-and-hold gesture hook — pointer-captured, move-tolerant, keyboard-reachable, with 0–1 progress for your own ring.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-long-press.jsonPrompt
Build a React + TypeScript "useLongPress" hook (React only, no other dependencies).
Contract
- `useLongPress<T extends HTMLElement = HTMLElement>(options): { handlers, isPressing,
progress }`.
- options: `{ onLongPress(meta), onStart?(meta), onCancel?(reason, meta),
onFinish?(meta), threshold = 500, moveTolerance = 10, disabled = false,
captureEvent = false }`.
- `meta` = `{ source: "pointer" | "keyboard"; pointerType: string | null }`
(pointerType is the PointerEvent's "mouse" | "touch" | "pen", null for keyboard).
- `reason` = `"release" | "move" | "leave" | "cancel" | "disabled"`:
release = let go before the threshold (this IS a plain tap/click — consumers use
it to run their single-click action), move = travelled past moveTolerance,
leave = pointer left the element (only reachable when pointer capture failed),
cancel = the browser/OS took the gesture (pointercancel: scroll, pinch, system
gesture) or the element blurred while a key was held, disabled = `disabled`
flipped to true mid-press.
- `handlers` is a spreadable prop bag: `onPointerDown/Move/Up/Leave/Cancel`,
`onContextMenu`, `onKeyDown`, `onKeyUp`, `onBlur`. It is reference-stable across
renders (every handler is a `useCallback` with an empty dep list reading options
through a ref), so memoised children never re-render because of it.
- `isPressing` stays true from press start until the press ends — including the
window after `onLongPress` fired but before the finger/button is lifted.
- `progress` is 0→1 while charging, pinned to 1 the instant `onLongPress` fires,
and reset to 0 when the press ends. The hook renders nothing; the consumer draws
the ring/bar.
Behavior
- One press at a time: a second pointer going down while a press is active is
ignored, and only the pointer that started the press (matched by `pointerId`)
can end it — a stray second finger must not kill a legitimate hold.
- Pointer Events unify mouse/touch/pen; the mouse path only accepts the primary
button (`button === 0`) so right/middle clicks never start a hold.
- On pointerdown, call `setPointerCapture(pointerId)` on the element inside a
try/catch. Capture is what makes "hold, slide off the element, release" still
deliver `pointerup` to you. When capture is unavailable (jsdom, old WebViews),
degrade gracefully: `onPointerLeave` cancels the press instead. Conversely, when
capture DID take, `onPointerLeave` must not cancel (boundary events are
suppressed while captured; a stray one should not abort a valid hold).
- Movement cancels: on every move of the owning pointer, compare
`Math.hypot(dx, dy)` against moveTolerance and `onCancel("move")` past it. This
is the single most important rule on touch — a user starting to scroll a list
always drags a few pixels, and without it every scroll attempt fires a long
press. Once `onLongPress` has fired, movement stops cancelling (the gesture
already succeeded; what happens next — e.g. a drag — is the consumer's call).
- `onContextMenu` calls `preventDefault()` only while one of the hook's own
pointer presses is in flight: touch long-press otherwise pops the system callout
and steals the gesture, while a desktop right-click (which never starts a press
here) keeps the element's normal context menu.
- Keyboard parity: holding Space or Enter starts a press. Auto-repeat keydown
(`event.repeat`) must be dropped — otherwise the timer restarts every ~30ms and
the threshold is never reached. Space's default is prevented so the page does not
scroll (which on a native button also suppresses the click it would fire on
keyup); Enter's default is deliberately left alone, so an element that still has
its own `onClick` keeps native Enter activation — one more reason to read taps
from `onCancel("release")` instead. `onBlur` ends a key-held press with
`"cancel"`, because tabbing or
switching windows means `keyup` never arrives and the press would otherwise stay
stuck "down" forever. Blur must NOT touch a pointer press (pointer holds do not
depend on focus).
- Timing: `setTimeout(threshold)` fires the callback (frame-rate independent) and
a `requestAnimationFrame` loop only advances `progress`; when the timer fires,
cancel the rAF and pin progress to exactly 1 so the ring never freezes at 98%.
The rAF loop is owned by the press object that started it, so a stale frame from
a previous press can never paint over a new one.
- Threshold/tolerance are clamped and snapshotted at press start: `threshold <= 0`
or non-finite would divide progress by zero and turn every tap into a long press,
so clamp to >= 1ms (non-finite falls back to the default), and clamp
moveTolerance to >= 0. Snapshotting means changing `threshold` mid-press cannot
desynchronise the timer from the progress bar.
- `disabled` short-circuits pointerdown/keydown; flipping it to true mid-press is
noticed by the rAF loop within a frame and ends the press with
`onCancel("disabled")`.
- Every callback lives behind a latest-ref synced in an effect, so inline arrow
functions from the consumer never rebuild timers, and no callback ever appears
in a dependency array. Cleanup clears both the timeout and the rAF on cancel,
end and unmount; unmount only frees resources and deliberately fires no callback.
- `captureEvent: true` makes the hook call `preventDefault()` + `stopPropagation()`
on the events it actually consumes, so the gesture is taken away from ancestor
click/drag handlers and from native behaviours (text selection, image drag).
Note it also suppresses the focus a pointerdown would give the element — leave it
off unless you need it, or call `element.focus()` yourself in `onStart`.
Rendering & styling
- The hook has no DOM and no styling opinion. Consumers spread `handlers` onto the
element and drive visuals from `isPressing`/`progress` with semantic tokens
(`bg-primary`, `bg-destructive/10`, `stroke-destructive`, `border-primary`,
`text-muted-foreground`), plus `focus-visible:ring-2 ring-ring` on the target.
- Touch hygiene belongs to the consumer: put `touch-none` (or a narrower
`touch-action`) and `select-none` on the press target so iOS does not raise the
selection magnifier and Android does not steal the gesture for scrolling. If the
target sits inside a scrollable list and must stay scrollable, keep
`touch-action: auto` and let the browser's `pointercancel` + the move tolerance
hand the gesture back to scrolling.
- Give the target a real accessible name (`aria-label="Hold to delete …"`) and,
for non-button hosts, `role="button"` + `tabIndex={0}` so the keyboard path is
reachable.
- `prefers-reduced-motion` does not gate anything: `progress` is functional
feedback ("how much longer"), not decoration. Any transition a consumer layers
on top of it should still carry `motion-reduce:transition-none`.
Customization levers
- Threshold per surface: 400–500ms for "reveal a menu", 700–1200ms for destructive
holds. Tolerance: 8–12px on touch, 3–5px for a mouse-only surface.
- Tap-vs-hold on one element: run the click action from `onCancel("release")` and
attach no `onClick` at all — that is the only way to keep a native click from
firing on top of a completed long press. If you must keep `onClick`, gate it on a
ref flag set in `onLongPress`.
- Progress is optional: ignore it and the hook is a plain gesture recogniser. If
you keep it, remember it re-renders the consumer once per frame while pressing —
isolate the ring in a small child component when the surrounding tree is heavy,
or drive a pure CSS keyframe off `isPressing` instead.
- Haptics/audio: fire `navigator.vibrate?.(10)` in `onStart` and again in
`onLongPress` for a native-feeling hold on Android.
- Composing extra handlers: spread `handlers` first, then wrap the one you need and
call the hook's version inside it
(`onPointerMove={e => { handlers.onPointerMove(e); mine(e) }}`). Replacing
`onPointerMove` outright silently disables move-cancel.
- Repeat-while-held (key-repeat style actions) is intentionally out of scope: add
an interval started in `onLongPress` and cleared in `onFinish`/`onCancel`.Concepts
- Pointer capture as the release guarantee — capturing the pointer on
pointerdownis what makes "press, slide off the element, let go" still deliverpointerupto the element that owns the gesture; without it a press that ends outside its target never ends at all. When capture is unavailable the hook falls back to cancelling onpointerleave. - Move tolerance = handing the gesture back to scrolling — on touch, the first few pixels of a scroll look exactly like the start of a long press. Cancelling past ~10px is what keeps a list scrollable while its rows are still long-pressable; the browser's own
pointercancelis the second safety net. - Tap and hold on one element — a press that ends before the threshold is reported as
onCancel("release"), which is the click. Reading the tap from there (instead of a separateonClick) is what stops a completed long press from also firing the element's click handler. - Timer fires, rAF only paints — the callback is owned by
setTimeoutso it stays frame-rate independent, whilerequestAnimationFrameexists purely to moveprogress; pinning progress to 1 when the timer lands hides the frame of drift between the two clocks. - Held keys auto-repeat — a key held down re-fires
keydownevery few tens of milliseconds. Droppingevent.repeatis the difference between "hold Space for 500ms" working and a timer that restarts forever; the matching hazard iskeyupnever arriving after focus leaves, which is why blur ends a key-held press. - Behaviour hook vs finished button — this hook recognises the gesture and hands back
isPressing/progress;buttons/hold-to-confirmis the packaged button that already draws the fill and owns aconfirmedend state. Use the component when you want the standard confirm button, the hook when the long press belongs to your own markup.