Mobile

Long Press

Press-and-hold on any row or tile — a ring, bar or swell charges under the finger, drift hands the gesture back to the scroller, and the completion fires exactly once, with a tap, a held key and a 44px button all reaching the same action.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"

/** How the completion was reached. `button` is the always-visible non-gesture path. */
export type LongPressSource = "pointer" | "keyboard" | "button"

/** How the charge is drawn. All three make the same promise, only the surface differs. */
export type LongPressVariant = "ring" | "bar" | "swell"

/** Mirrored onto the root and onto the pressable as `data-phase`. */
export type LongPressPhase = "idle" | "pressing" | "fired"

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "LongPress" component — the touch screen's
missing right-click: a wrapper that adds a press-and-hold second action to a row,
a tile or a card while a tap keeps doing the primary thing. React + lucide-react
(Check, MoreHorizontal) only; no gesture library, no animation library.

Contract
- "use client". forwardRef<HTMLDivElement, LongPressProps> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "onClick">. The rest props and the
  forwarded ref go to a positioning root; `className` merges (via cn) onto the
  pressable target instead, because the target is the visible surface — consumers
  write `border bg-card px-3 py-2.5` and expect the charge to be clipped by it.
- Props:
  - onLongPress(detail) — fires exactly once per press, the instant the hold
    reaches `duration`. The finger may still be down afterwards.
  - onTap?(detail) — the primary action: a press that ended before the threshold
    without drifting. A screen-reader activation lands here too.
  - detail = { source: "pointer" | "keyboard" | "button"; pointerType: string |
    null; clientX: number; clientY: number }. The coordinates are the contact
    point (the finger, else the centre of the target or of the button), so a
    consumer can open a menu where the thumb already is.
  - duration = 500, clamped 120..5000. moveTolerance = 10 (px, straight line),
    clamped 0..80. Both are snapshotted into the press at pointerdown, so editing
    them mid-press cannot desync the ring from the timer.
  - variant: "ring" | "bar" | "swell" = "ring". Three genuinely different
    presentations of the same recognition, all making the same promise: ring
    charges a 44px circle at the contact point (best on a chat bubble or a tile),
    bar charges a hairline along the bottom edge of the target (best on a
    full-width row, because it never covers the text), swell washes a disc out
    from the contact point while the target presses in (best on a photo tile).
  - scrollAxis: "y" | "x" | "none" = "y" → touch-action pan-y / pan-x / none.
    Which way the surrounding list scrolls is not knowable from inside.
  - selectable = false plus selected / defaultSelected / onSelectedChange —
    controlled and uncontrolled both supported. With selectable, a completed hold
    toggles selection (the mobile entry into multi-select), paints
    data-selected and reports aria-pressed. A tap always stays the primary
    action: what a tap means once selection mode is on is app state, not row state.
  - disabled = false, actionLabel = "More actions", label?, showActionButton =
    true, children? (an empty target is a real case and still 44px tall).
- Export the LongPressVariant / LongPressSource / LongPressPhase unions and the
  LongPressDetail interface, and mirror phase, variant and selected onto both the
  root and the target as data-attributes for skinning.
- Clamp every number through one helper that rejects NaN: a NaN duration divides
  into Infinity and turns every tap into a hold.

Behavior — the list scrolls, the hold is the guest
- Pointer Events only: one path for touch, mouse and pen, never parallel touch +
  mouse handlers. Mouse counts the primary button only.
- setPointerCapture on the target at pointerdown — the element that started the
  gesture. Without it, "hold, slide off the row, release" loses the release and
  the press stays down forever. Where capture throws (jsdom, old WebViews),
  pointerleave becomes the fallback cancel; where it worked, pointerleave is
  ignored on purpose.
- Drift past moveTolerance cancels immediately. This is the whole reason the
  component is safe inside a list: a finger reaching to scroll always drifts, so
  the hold gives the gesture back rather than firing under a flick. touch-action
  keeps the browser panning, and a browser-owned scroll arrives as pointercancel,
  which also cancels. Nothing here calls preventDefault on a scroll, so no
  non-passive listener is needed at all.
- onContextMenu calls preventDefault only while this component owns a pointer
  press: on touch, the system menu and the iOS selection loupe would otherwise
  pop mid-hold and steal it. A desktop right click never starts a press, so the
  browser's own menu still works. Pair it with select-none and
  -webkit-touch-callout: none.
- One rAF owns both the pixels and the firing — no setTimeout to race with it. It
  stops on the frame that reaches ratio 1, and the fired flag on the press record
  is written synchronously before onLongPress is called, so the completion cannot
  fire twice. A backgrounded tab pauses rAF, which is the behaviour you want: a
  hold cannot complete on a screen nobody is looking at.
- Once it has fired, further movement no longer cancels — the completion already
  happened, and the consumer may be dragging the lifted row.
- Keyboard: holding Space or Enter charges the same timer (auto-repeat keydowns
  dropped, Space's page scroll blocked for the whole hold), keyup before the
  threshold is a tap, and blur mid-hold ends it as a cancel because keyup will
  never arrive.
- The action button is the third path and always visible: 44px, a real <button>,
  firing the identical completion with source "button". It lives outside the
  target's DOM subtree (never nest a button inside role="button"), and the target
  reserves padding for it so long content truncates instead of sliding under.
- disabled blocks pointer, key and button paths, and a press already running is
  cancelled on the next frame if disabled flips true. It is never the native
  disabled attribute — the browser blurs a node the instant it is disabled, which
  would drop focus onto <body>.
- Cleanup: cancelAnimationFrame, the announcement timer and pointer capture are
  all released on unmount, and unmount sends no callback to a consumer that is
  gone.

Rendering & styling
- Semantic tokens only, monochrome: target rounded-2xl + select-none + the
  consumer's own chrome, ring disc bg-background/85 + text-foreground +
  backdrop-blur, ring track stroke-border, arc stroke-current, bar bg-border with
  a bg-foreground fill, swell bg-foreground/10, selection ring-2 ring-foreground,
  focus-visible:ring-2 ring-ring everywhere, action button text-muted-foreground
  → hover:bg-accent. Completion INVERTS (bg-foreground text-background) rather
  than taking a colour; colour is reserved for real semantics.
- The moving pixels bypass React: one paint() writes stroke-dashoffset /
  transform straight to the DOM from the rAF loop. Only the three phase changes
  (idle → pressing → fired) go through state, so holding one row does not
  re-render the list around it. Every RESTING position is painted from an effect
  keyed on phase, after the transition classes are back on the DOM — that is what
  makes a released indicator ease home instead of snapping.
- Safe area: the ring is positioned with one CSS expression that clamps the
  contact point twice — inside
  `max(var(--safe-area-inset-<edge>, env(safe-area-inset-<edge>, 0px)))` so a
  press on the bottom row never draws a ring under the home indicator or up in
  the notch, and inside the target's own box so the clipped surface never eats
  it. It stays in CSS because env() cannot be read from JS and a rotation must
  re-solve it without a re-render.
- Accessibility: the target is role="button" tabIndex=0 with aria-label,
  aria-describedby pointing at an sr-only "Press and hold to <action>" hint, and
  aria-pressed only when selectable. A polite sr-only role="status" announces the
  outcome only — never the charge, which would be a screen-reader storm — and it
  is cleared after ~1.4s so the next identical outcome can be announced again.
  When selection is controlled the announcement is skipped entirely: the parent
  may refuse, and announcing a refusal as success would be a lie; aria-pressed
  tracks the resolved value.
- prefers-reduced-motion: the charge keeps running, because it is information
  (how much longer to hold), not decoration. What drops is the easing back to
  rest and the swell's press-in squeeze, both applied through motion-safe: /
  motion-reduce: so nothing depends on reading matchMedia.

Customization levers
- duration is how deliberate the gesture is: 400-500 for a menu, 650-900 for
  something destructive, never under ~150 or it collides with a tap.
- moveTolerance is the scroll arbitration knob: 10 inside a scrolling list, 20-24
  for a static tile grid where nothing can be stolen.
- variant is the surface, not a colour swap — pick per container (ring on
  bubbles and tiles, bar on full-width rows, swell on photos). swell squeezes the
  target, so pair it with showActionButton={false} or accept a button that stays
  put while the tile presses in.
- selectable turns the same gesture into the multi-select entry point; leave it
  off and the component is a pure recogniser.
- Skin by data-attribute: data-phase="pressing | fired", data-selected,
  data-variant, and data-slot="long-press-target | long-press-ring |
  long-press-bar | long-press-swell | long-press-action".
- showActionButton={false} only when you render your own control calling the same
  handler — keep one of the two, never zero, or the action is touch-only.
- className lands on the target: give it the card chrome (border, bg, padding,
  radius) and the charge is clipped to exactly that shape. Layout classes for the
  outer box (a flex item that must not shrink, a grid span) belong on your own
  wrapper around it. Wrap content, never other buttons or links: the target is a
  role="button".

Concepts

  • Tap versus hold — one press decides between two actions on the same pixels: release early and it is the primary tap, hold past the threshold and it is the second action. Touch has no right-click, so this split is the only place a second action can live.
  • Drift cancels — past a ~10px straight-line move the press is abandoned rather than completed. A finger reaching to scroll always drifts, so this single rule is what keeps a long-press row safe inside a scrolling list.
  • Pointer capture — the target claims the pointer at press time, so the release still arrives after the finger has slid off the row; without it a hold that ends elsewhere stays down forever.
  • One-shot completion — the fired flag lives on the press record and is written synchronously before the callback runs, so a completion can never double-fire, and a rAF that stops on the completion frame leaves no timer to race with.
  • Charge at the contact point — the progress ring is drawn where the finger actually landed, clamped out of the device insets so a press on the bottom row never charges under the home indicator.
  • Equal non-gesture path — a held Space or Enter charges the same timer and an always-visible 44px button fires the same completion, so nothing about the feature is reachable by touch alone.

On This Page