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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/long-press.jsonPrompt
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.