Gesture libraries such as @use-gesture/react handle most of what follows. Reach for one first. Build it by hand only when a dependency is not worth it, and then implement every rule below, because each one exists to fix a specific way hand rolled gestures break.
Every Gesture Has Three Events
Start, move, and end. A gesture that only responds on end is not a gesture, it is a button with extra steps. Users can feel the difference immediately at low speed.
| Event | Responsibility |
|---|---|
| Start | Set constraints. Capture the pointer, lock the cursor, disable selection, record the origin |
| Move | Give continuous feedback. Translate, scale, rotate, or otherwise track the input one to one |
| End | Release constraints and settle. Snap to the nearest valid state using velocity |
Write the moving value directly during move, without animating it. Animating on every move event fights the user's finger.
function onPan(_: PointerEvent, { delta }: PanInfo) {
// jump() sets the value with no transition. The user's input is the animation.
y.jump(clamp(y.get() + delta.y, 0, SNAPPED_Y));
}
Contain the Gesture
A drag routinely takes the pointer outside the target and over other elements. Left alone, the browser will select text under the pointer and swap the cursor to whatever it passes over. Both make a custom gesture feel broken.
Disable pointer events and selection for the whole lifecycle of the gesture, and pin the cursor:
.gesture-grabbing {
cursor: grabbing;
user-select: none;
-webkit-user-select: none; /* Safari */
* {
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
}
Toggle it from one helper per gesture type so start and end always stay in sync:
export const grab = {
start: () => document.body.classList.add("gesture-grabbing"),
end: () => document.body.classList.remove("gesture-grabbing"),
};
Always remove the class on end, on cancel, and on unmount. A leaked class leaves the whole page unclickable.
Separate a Drag From a Click
A browser click is a pointerdown followed by a pointerup, and a drag fires both. So a draggable element that is also clickable will fire its click handler at the end of every drag.
Track a small state machine and swallow the click when the pointer moved:
type GestureState = "idle" | "press" | "drag" | "drag-end";
function onPointerMove() {
if (state.current === "press") state.current = "drag";
}
function onPointerUp() {
if (state.current === "drag") state.current = "drag-end";
}
function onClick(event: MouseEvent) {
if (state.current !== "drag-end") return;
event.preventDefault();
event.stopPropagation();
state.current = "idle";
}
Require a Movement Threshold
Not everyone has steady hands. A press that drifts 2 pixels should stay a press. Require a few pixels of travel before a drag activates, measured as total distance rather than per axis.
const DRAG_THRESHOLD = 10;
function onPointerMove(event: PointerEvent) {
if (state.current !== "press") return;
const dx = event.clientX - origin.current.x;
const dy = event.clientY - origin.current.y;
if (Math.hypot(dx, dy) >= DRAG_THRESHOLD) {
state.current = "drag";
}
}
Scale the threshold with the target size. A few pixels suits most controls. It costs nothing perceptible when the drag is deliberate, and it removes a whole class of accidental triggers.
Keep the Pointer Bound to the Target
If the pointer leaves the element, the element stops receiving events and the gesture dies mid motion. This is most obvious when a drag starts near an edge or the pointer outruns a spring.
setPointerCapture routes every subsequent pointer event to the captured element regardless of where the pointer goes:
function onPointerDown(event: React.PointerEvent) {
ref.current?.setPointerCapture(event.pointerId);
}
function onPointerUp(event: React.PointerEvent) {
ref.current?.releasePointerCapture(event.pointerId);
}
Once captured, ignore additional pointers. If a second finger lands mid drag, the element can jump to it.
Declare touch-action for Custom Touch Gestures
Without touch-action, the browser assumes any touch drag is a scroll and takes the gesture from you. On touch hardware a custom drag simply will not work.
Be specific about what you are disabling rather than reaching for none by default:
| Value | Effect |
|---|---|
none | You own all panning and zooming on this element |
pan-y | Browser keeps vertical scroll, you take horizontal |
pan-x | Browser keeps horizontal scroll, you take vertical |
A horizontal carousel inside a scrolling page wants pan-y, not none, so the page still scrolls when the user swipes up through it.
Project Where a Flick Would Land
A swipe is a drag that accounts for velocity. Deciding a snap from distance alone forces the user to drag all the way past the threshold, which feels heavy.
On release, project where the element would come to rest if it kept moving, and snap based on that. The deceleration constant below is the one iOS uses for scroll views:
function project(initialVelocity: number, decelerationRate = 0.998) {
return ((initialVelocity / 1000) * decelerationRate) / (1 - decelerationRate);
}
function onPanEnd(_: PointerEvent, { velocity }: PanInfo) {
const projected = y.get() + project(velocity.y);
y.set(projected >= SNAP_DISTANCE ? SNAPPED_Y : 0);
}
Slow, long drags stay precise because the projection is small. A short flick now completes the action. One code path serves both.
Dampen Movement Past a Boundary
When a gesture reaches the end of its range, stopping dead feels like a bug. Let the element continue with progressively less movement, then spring back. This communicates the boundary without an indicator and keeps the surface responsive to input.
function dampen(value: number, [min, max]: [number, number], factor = 2): number {
if (value > max) {
const extra = value - max;
return max + Math.sqrt(extra) * factor;
}
if (value < min) {
const extra = min - value;
return min - Math.sqrt(extra) * factor;
}
return value;
}
function onPan(_: PointerEvent, { offset }: PanInfo) {
y.set(dampen(offset.y, [-max, max]));
}
The square root is what produces the feel: early resistance is gentle and grows the further the user pushes.
Damping also has a second use inside range. Moving an element less than the dragged distance makes it feel heavier and harder to trigger by accident, which suits a surface you do not want dismissed casually. Reset the damping factor to 1 once it commits, so the release reads as the constraint letting go.
Interpolate Secondary Elements From the Primary
Once one element tracks the gesture, derive everything else from its value rather than animating separately on start and end. Secondary elements then respond continuously, and the user can reverse the gesture halfway and see the interface follow.
// Backdrop responds to the sheet's position, not to open and close events.
const opacity = useTransform(y, [0, max], [0.2, 0]);
const blur = useTransform(y, [0, max], [12, 0]);
This is what allows peeking. The user drags a sheet down to read what is behind it and returns without ever releasing.
Checklist
- A gesture library was considered before hand rolling
- Start, move, and end are all handled
- The moving value is set directly during move, not animated
- Pointer events, text selection, and the cursor are locked for the gesture lifecycle
- The containment class is removed on end, on cancel, and on unmount
- Click is suppressed after a drag on elements that are both draggable and clickable
- A movement threshold guards against accidental drags
-
setPointerCapturekeeps the gesture alive outside the element bounds - Extra touch points are ignored once a gesture owns the element
-
touch-actionis set, and scoped to the axis you actually need - Snap decisions use projected position from velocity, not distance alone
- Boundaries dampen and spring back rather than clamping
- Secondary elements interpolate from the primary value
- The action is also reachable without the gesture, by button or keyboard
-
prefers-reduced-motionsettles instantly while keeping the same snap decision