## Long Press — Gesture Intent confirmed by time, and cancelled by everything else. Docs: https://www.interior.dev/docs/long-press Reference: https://www.interior.dev/reference/long-press License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/long-press.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx import { LongPressButton, useLongPress } from "@/components/interior/long-press"; export function ArchiveRow({ onArchive }: { onArchive: () => void }) { return Hold to archive; } export function Tile({ onOpen }: { onOpen: () => void }) { const { bind, step, steps, holding } = useLongPress({ onLongPress: onOpen, duration: 700, }); return (
Press and hold {step} / {steps}
); } ``` ### Props - `onLongPress` (`() => void`) Fires once, when the press survives the full duration - `duration` (`number`) — default: `550` Milliseconds the press has to survive - `steps` (`number`) — default: `12` Cells drawn, and the render budget for the whole gesture - `moveTolerance` (`number`) — default: `8` Pixels of drift allowed before it is treated as a scroll (hook only) - `haptic` (`boolean`) — default: `true` Buzz on commit where the platform supports it (hook only) - `onCancel` (`() => void`) Fires when a press is abandoned, never after it commits (hook only) - `disabled` (`boolean`) — default: `false` Refuses to start - `className` (`string`) Applied to the button ### Behavior notes - Drifting more than eight pixels cancels the press. On a phone this is the difference between holding a row and scrolling past it, and it is the reason most long-press implementations feel broken on touch. - The click that follows a completed press is swallowed. Otherwise the element runs its normal tap action immediately after the hold already ran. - Progress is reported in discrete steps, not as a float, so a 550ms press costs twelve renders instead of thirty-three. - Losing the window, hiding the tab, a pointercancel from the browser taking over the gesture, or Escape all end the press. A hold that survives you switching apps is not a hold. - Space and Enter hold too, so the gesture is reachable without a pointer, and the duration is announced through a described-by hint. - The native touch callout and context menu are suppressed on the target, because on touch platforms they are the default answer to a long press. ### Source (`components/interior/long-press.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const POP = { type: "spring", stiffness: 640, damping: 22, mass: 0.7 } as const; const INSTANT = { duration: 0 } as const; export type UseLongPressOptions = { onLongPress: () => void; duration?: number; steps?: number; moveTolerance?: number; haptic?: boolean; disabled?: boolean; onCancel?: () => void; }; type Phase = "idle" | "holding" | "fired"; export function useLongPress({ onLongPress, duration = 550, steps = 12, moveTolerance = 8, haptic = true, disabled = false, onCancel, }: UseLongPressOptions) { const cells = Math.max(1, Math.round(steps)); const [step, setStep] = useState(0); const [holding, setHolding] = useState(false); const phase = useRef("idle"); const raf = useRef(0); const startedAt = useRef(0); const origin = useRef<{ x: number; y: number } | null>(null); const settle = useRef | null>(null); const fire = useRef(onLongPress); fire.current = onLongPress; const cancelled = useRef(onCancel); cancelled.current = onCancel; const reset = useCallback(() => { cancelAnimationFrame(raf.current); raf.current = 0; if (settle.current) { clearTimeout(settle.current); settle.current = null; } origin.current = null; phase.current = "idle"; setHolding(false); setStep(0); }, []); const end = useCallback(() => { if (phase.current !== "holding") return; reset(); cancelled.current?.(); }, [reset]); const begin = useCallback( (point?: { x: number; y: number }) => { if (disabled || phase.current !== "idle") return; phase.current = "holding"; origin.current = point ?? null; startedAt.current = performance.now(); setHolding(true); setStep(0); const tick = (now: number) => { const p = Math.min(1, (now - startedAt.current) / duration); const s = Math.floor(p * cells); setStep((prev) => (prev === s ? prev : s)); if (p < 1) { raf.current = requestAnimationFrame(tick); return; } raf.current = 0; phase.current = "fired"; setStep(cells); if (haptic) navigator.vibrate?.(12); fire.current(); settle.current = setTimeout(() => { if (phase.current === "fired") reset(); }, 260); }; raf.current = requestAnimationFrame(tick); }, [disabled, duration, cells, haptic, reset], ); useEffect(() => { const bail = () => end(); const onVisibility = () => document.hidden && end(); window.addEventListener("blur", bail); document.addEventListener("visibilitychange", onVisibility); return () => { window.removeEventListener("blur", bail); document.removeEventListener("visibilitychange", onVisibility); cancelAnimationFrame(raf.current); if (settle.current) clearTimeout(settle.current); }; }, [end]); const bind = { onPointerDown: (e: React.PointerEvent) => { if (e.button !== 0 && e.pointerType === "mouse") return; e.currentTarget.setPointerCapture?.(e.pointerId); begin({ x: e.clientX, y: e.clientY }); }, onPointerMove: (e: React.PointerEvent) => { const from = origin.current; if (phase.current !== "holding" || !from) return; if (Math.hypot(e.clientX - from.x, e.clientY - from.y) > moveTolerance) { end(); } }, onPointerUp: end, onPointerCancel: end, onPointerLeave: end, onKeyDown: (e: React.KeyboardEvent) => { if (e.repeat) return; if (e.key === " " || e.key === "Enter") { e.preventDefault(); begin(); } }, onKeyUp: (e: React.KeyboardEvent) => { if (e.key === " " || e.key === "Enter") end(); if (e.key === "Escape") end(); }, onBlur: end, onClick: (e: React.MouseEvent) => { if (phase.current === "fired") { e.preventDefault(); e.stopPropagation(); } }, onContextMenu: (e: React.MouseEvent) => e.preventDefault(), }; return { bind, step, steps: cells, holding, fired: step === cells, progress: step / cells, }; } export type LongPressButtonProps = { onLongPress: () => void; children: React.ReactNode; duration?: number; steps?: number; disabled?: boolean; className?: string; }; export function LongPressButton({ onLongPress, children, duration = 550, steps = 12, disabled = false, className = "", }: LongPressButtonProps) { const hintId = useId(); const reduced = useReducedMotion() === true; const { bind, step, steps: cells, holding, fired } = useLongPress({ onLongPress, duration, steps, disabled, }); const progress = fired ? 1 : step / cells; return ( {children} {children} Press and hold for {Math.round(duration / 100) / 10} seconds to confirm ); } ```