Hooks

useLongPress

A press-and-hold gesture hook — pointer-captured, move-tolerant, keyboard-reachable, with 0–1 progress for your own ring.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

const DEFAULT_THRESHOLD = 500
const DEFAULT_MOVE_TOLERANCE = 10
/** Threshold floor: `threshold <= 0` turns `elapsed / threshold` into Infinity/NaN and makes every tap a long press. */
const MIN_THRESHOLD = 1
/** "Holding" on a keyboard means holding Space or Enter — the same keys that activate a native button. */
const ACTIVATION_KEYS = new Set([" ", "Enter"])

export type LongPressSource = "pointer" | "keyboard"

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-long-press.json

Prompt

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 pointerdown is what makes "press, slide off the element, let go" still deliver pointerup to 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 on pointerleave.
  • 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 pointercancel is 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 separate onClick) 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 setTimeout so it stays frame-rate independent, while requestAnimationFrame exists purely to move progress; 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 keydown every few tens of milliseconds. Dropping event.repeat is the difference between "hold Space for 500ms" working and a timer that restarts forever; the matching hazard is keyup never 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-confirm is the packaged button that already draws the fill and owns a confirmed end state. Use the component when you want the standard confirm button, the hook when the long press belongs to your own markup.

On This Page