## 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
);
}
```