Press and hold with a filling ring, right click and Shift F10 included.
Gestures & dragmotion
01Preview
Shared with you
1 selected- Press and hold for file actions, or press Shift F10
- Press and hold for file actions, or press Shift F10
- Press and hold for file actions, or press Shift F10
Hold a file for its actions, or right click itPress and hold a file for its actions
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { LongPress, useLongPress } from "@/components/ui/long-press";
<LongPress onLongPress={(e) => openMenuFor(file, e)}>
{(a11y) => <FileTile file={file} {...a11y} />}
</LongPress>
// Or the hook alone, on your own element.
const { bind, progress, state } = useLongPress({ onLongPress: startRecording, duration: 350 });
<button {...bind}>Hold to talk</button>04Source
"use client";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
export type LongPressPointer = "mouse" | "touch" | "pen" | "keyboard";
export type LongPressEvent = {
/** Where the press happened, relative to the element. The element's center for keyboard. */
x: number;
y: number;
clientX: number;
clientY: number;
pointerType: LongPressPointer;
/** True when a right click or the context menu key asked for it directly, with no hold. */
immediate: boolean;
target: HTMLElement;
};
export type LongPressState = "idle" | "pressing" | "fired";
export type UseLongPressOptions = {
onLongPress: (event: LongPressEvent) => void;
/** Milliseconds the press must be held. */
duration?: number;
/** Milliseconds before any feedback shows, so a tap never flashes a ring. */
delay?: number;
/** Pixels the pointer may wander before the press counts as a scroll or drag and cancels. */
tolerance?: number;
/** A right click, the context menu key or Shift+F10 also fire it, instantly. */
contextMenu?: boolean;
onStart?: () => void;
onCancel?: () => void;
disabled?: boolean;
};
type Bind = {
onPointerDown: (e: React.PointerEvent<HTMLElement>) => void;
onPointerMove: (e: React.PointerEvent<HTMLElement>) => void;
onPointerUp: (e: React.PointerEvent<HTMLElement>) => void;
onPointerCancel: (e: React.PointerEvent<HTMLElement>) => void;
onContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
onClickCapture: (e: React.MouseEvent<HTMLElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
};
/**
* Press-and-hold on anything. Returns handlers to spread on the element, the press state,
* a 0–1 progress value to draw from, and where the press began.
*/
export function useLongPress({
onLongPress,
duration = 500,
delay = 120,
tolerance = 10,
contextMenu = true,
onStart,
onCancel,
disabled = false,
}: UseLongPressOptions) {
const [state, setState] = useState<LongPressState>("idle");
const [origin, setOrigin] = useState<{ x: number; y: number; pointerType: LongPressPointer } | null>(null);
const progress = useMotionValue(0);
const anim = useRef<AnimationPlaybackControls | null>(null);
const p = useRef({ id: -1, x0: 0, y0: 0, type: "mouse" as LongPressPointer, fired: false, target: null as HTMLElement | null });
const swallowClick = useRef(false);
useEffect(() => () => anim.current?.stop(), []);
const fire = (target: HTMLElement, clientX: number, clientY: number, pointerType: LongPressPointer, immediate: boolean) => {
const r = target.getBoundingClientRect();
onLongPress({ x: clientX - r.left, y: clientY - r.top, clientX, clientY, pointerType, immediate, target });
};
const reset = (drain: boolean) => {
anim.current?.stop();
// Drains faster than it fills, from wherever it got to.
if (drain) anim.current = animate(progress, 0, { duration: Math.max(0.12, progress.get() * 0.25), ease: "easeOut" });
else progress.set(0);
};
const cancel = () => {
if (p.current.id === -1) return;
p.current.id = -1;
if (!p.current.fired) {
reset(true);
setState("idle");
onCancel?.();
}
};
const bind: Bind = {
onPointerDown(e) {
if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
const target = e.currentTarget;
const r = target.getBoundingClientRect();
const type = e.pointerType as LongPressPointer;
p.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, type, fired: false, target };
swallowClick.current = false;
setOrigin({ x: e.clientX - r.left, y: e.clientY - r.top, pointerType: type });
setState("pressing");
onStart?.();
anim.current?.stop();
progress.set(0);
anim.current = animate(progress, 1, {
duration: duration / 1000,
ease: "linear",
onComplete: () => {
if (p.current.id !== e.pointerId) return;
p.current.fired = true;
swallowClick.current = true;
setState("fired");
navigator.vibrate?.(10);
fire(target, p.current.x0, p.current.y0, type, false);
},
});
},
onPointerMove(e) {
if (p.current.id !== e.pointerId || p.current.fired) return;
if (Math.hypot(e.clientX - p.current.x0, e.clientY - p.current.y0) > tolerance) cancel();
},
onPointerUp(e) {
if (p.current.id !== e.pointerId) return;
if (p.current.fired) {
p.current.id = -1;
reset(false);
setState("idle");
} else cancel();
},
onPointerCancel(e) {
if (p.current.id === e.pointerId) cancel();
},
onContextMenu(e) {
// Touch and pen raise their own context menu mid-hold; the timer owns that gesture.
if (p.current.id !== -1 && p.current.type !== "mouse") {
e.preventDefault();
return;
}
if (!contextMenu || disabled || e.defaultPrevented) return;
e.preventDefault();
fire(e.currentTarget, e.clientX, e.clientY, "mouse", true);
},
onClickCapture(e) {
// The release that ends a long press is not also a tap.
if (!swallowClick.current) return;
swallowClick.current = false;
e.preventDefault();
e.stopPropagation();
},
onKeyDown(e) {
if (!contextMenu || disabled) return;
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
fire(e.currentTarget, r.left + r.width / 2, r.top + r.height / 2, "keyboard", true);
}
},
};
return { bind, state, progress, origin, cancel };
}
/* -------------------------------------------------------------------------------------------------
* The component: the hook plus a ring that fills under the press
* -----------------------------------------------------------------------------------------------*/
type A11yProps = { "aria-describedby": string; "aria-keyshortcuts"?: string };
// The press handlers belong to the gesture; reach for useLongPress to compose your own.
export type LongPressProps = Omit<
React.ComponentProps<"div">,
| "children"
| "contextMenu"
| "onContextMenu"
| "onPointerDown"
| "onPointerMove"
| "onPointerUp"
| "onPointerCancel"
| "onClickCapture"
| "onKeyDown"
// Motion owns these on the root.
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "onAnimationStart"
| "onAnimationEnd"
> &
Omit<UseLongPressOptions, "disabled"> & {
/** The pressable thing. As a function it receives the hint's id and shortcut to put on your focusable element. */
children: React.ReactNode | ((a11y: A11yProps) => React.ReactNode);
/** Read by screen readers after the element's name. */
hint?: string;
/** The element sinks slightly while held, and springs back when it fires. */
sink?: boolean;
disabled?: boolean;
};
export function LongPress({
children,
onLongPress,
duration = 500,
delay = 120,
tolerance,
contextMenu = true,
onStart,
onCancel,
hint = "Press and hold for more options",
sink = true,
disabled = false,
className,
style,
...rest
}: LongPressProps) {
const reduce = useReducedMotion();
const hintId = useId();
const { bind, state, progress, origin } = useLongPress({ onLongPress, duration, delay, tolerance, contextMenu, onStart, onCancel, disabled });
const pop = useMotionValue(0);
// Nothing shows until the delay has passed; then the ring grows in and fills with the time left.
const lead = Math.min(0.6, delay / duration);
const shown = useTransform(() => Math.min(1, Math.max(0, (progress.get() - lead) / 0.12)));
const fill = useTransform(() => Math.max(0, (progress.get() - lead) / (1 - lead)));
const depth = useMotionValue(1);
useEffect(() => {
const moves: AnimationPlaybackControls[] = [];
if (state === "fired") {
if (reduce) pop.set(1);
else {
// The completed ring flares out once, and the element springs back up to meet what opened.
pop.set(0);
moves.push(animate(pop, 1, { duration: 0.32, ease: ease.out }));
}
}
if (!reduce && sink) {
// Held, it sinks for the length of the hold; let go early and it rises straight back.
if (state === "pressing") moves.push(animate(depth, 0.97, { duration: duration / 1000, ease: ease.outQuart }));
else moves.push(animate(depth, 1, state === "fired" ? spring.pop : { duration: 0.15, ease: ease.out }));
}
return () => moves.forEach((m) => m.stop());
}, [state, pop, depth, reduce, sink, duration]);
const a11y: A11yProps = { "aria-describedby": hintId, ...(contextMenu ? { "aria-keyshortcuts": "Shift+F10" } : {}) };
const touch = origin?.pointerType === "touch";
return (
<motion.div
data-state={state}
data-disabled={disabled || undefined}
{...bind}
style={{ ...style, scale: depth }}
className={cn(
"relative select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]",
className,
)}
{...rest}
>
{typeof children === "function" ? children(a11y) : children}
<span id={hintId} className="sr-only">
{hint}
{contextMenu ? ", or press Shift F10" : ""}
</span>
{origin && (
<Ring
key={`${origin.x},${origin.y}`}
x={origin.x}
y={origin.y}
// A fingertip covers a small ring, so touch draws one wide enough to show around it.
size={touch ? 72 : 40}
shown={shown}
fill={fill}
pop={pop}
fired={state === "fired"}
reduce={!!reduce}
/>
)}
</motion.div>
);
}
function Ring({
x,
y,
size,
shown,
fill,
pop,
fired,
reduce,
}: {
x: number;
y: number;
size: number;
shown: MotionValue<number>;
fill: MotionValue<number>;
pop: MotionValue<number>;
fired: boolean;
reduce: boolean;
}) {
const stroke = size > 50 ? 3 : 2.5;
const r = size / 2 - stroke;
const scale = useTransform(() => (reduce ? 1 : fired ? 1 + 0.25 * pop.get() : 0.6 + 0.4 * shown.get()));
const opacity = useTransform(() => (fired ? 1 - pop.get() : shown.get()));
return (
<motion.span
aria-hidden
style={{ left: x, top: y, width: size, height: size, marginLeft: -size / 2, marginTop: -size / 2, scale, opacity }}
className="pointer-events-none absolute z-10 grid place-items-center"
>
<span className="absolute inset-0 rounded-full bg-raised/90 shadow-pop" />
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="relative -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-fg/20" />
<motion.circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} strokeLinecap="round" className="stroke-fg" style={{ pathLength: fill }} />
</svg>
</motion.span>
);
}05Props
LongPress
| Prop | Type | Default | Description |
|---|---|---|---|
| onLongPress* | (event: LongPressEvent) => void | — | Fires once the hold completes, or at once for a right click or the context menu key. The event has the point, the pointer type and whether it was immediate. |
| children* | ReactNode | (a11y) => ReactNode | — | The pressable element. As a function it gets aria-describedby and aria-keyshortcuts to spread on your focusable element. |
| duration | number | 500 | Milliseconds the press must be held. |
| delay | number | 120 | Milliseconds before the ring appears, so a tap never flashes one. |
| tolerance | number | 10 | Pixels the pointer can drift before the press cancels as a scroll or drag. |
| contextMenu | boolean | true | Right click, the context menu key and Shift+F10 fire it instantly. |
| sink | boolean | true | The element sinks to 0.97 while held and springs back when it fires. |
| hint | string | "Press and hold for more options" | Read by screen readers after the element's name. |
| onStart | () => void | — | A press began. |
| onCancel | () => void | — | The press ended early, moved too far or was taken by a scroll. |
| disabled | boolean | false | Ignores presses and keys. |
useLongPress
| Prop | Type | Default | Description |
|---|---|---|---|
| onLongPress* | (event: LongPressEvent) => void | — | Same options as the component. Returns { bind, state, progress, origin, cancel }. |
| bind | object | — | Pointer, context menu, click and key handlers to spread on the element. |
| state | "idle" | "pressing" | "fired" | — | Where the press is. |
| progress | MotionValue<number> | — | 0 to 1 over the hold, draining back when canceled. Draw your own feedback from it. |
| origin | { x, y, pointerType } | null | — | Where the latest press began, relative to the element. |
06Notes
Behavior
- Moving more than 10px cancels, and so does a scroll taking the gesture. Hold and scroll never fight.
- The click that ends a completed hold is swallowed, so a long press never also selects or opens the thing underneath. A plain tap is left alone.
- On touch, the browser's own long-press callout and context menu are suppressed while the hold is in progress; text elsewhere stays selectable.
- The fired event says how it was triggered, so a right click can open a menu at the pointer while a held finger or the keyboard opens it from the element.
Motion
- Nothing shows for the first 120ms. Then the ring grows in from 0.6 and fills linearly with the time left, because it measures time.
- The element sinks to 0.97 on the soft ease-out over the hold. On completion the ring flares to 1.25 and fades in 320ms while the element springs back up on the pop spring.
- Released early, the ring drains at four times its fill speed. Touch draws a 72px ring so it shows around a fingertip; a mouse gets 40px.
- Reduced motion keeps the ring filling (it carries the timing) and drops the sink, the grow-in scale and the flare.
Accessibility
- Holding isn't required: Shift+F10 and the context menu key fire it from the keyboard, and right click does it for a mouse.
- The hint is linked with aria-describedby and the shortcut is exposed with aria-keyshortcuts on the focusable child.
- The ring is decorative and hidden from assistive tech; what the press opens should manage focus itself, as a menu does.