Drag arbitration
拖拽判定 · drag-arbitrationWhen a finger goes down you do not yet know the intent: a tap, a horizontal drag, or just scrolling the page? Watch a few pixels, then decide.
Nearly every draggable gallery must handle tap-to-open, horizontal drag and vertical page scroll at once. Get the arbitration wrong and you get “I tried to scroll and moved the carousel”, “my tap counted as a drag”, or “the drag jumps when it starts”. Three small rules — a movement threshold, an axis bias and a tap slop — fix those three problems.
Formula
moved = |p − down| if moved < threshold → pending (nothing moves yet) else lock once: |dx| > bias·|dy| → axis x (let the page keep vertical scroll) |dy| > bias·|dx| → axis y otherwise → free report movement from the LOCK point, not the press point on release: maxMoved < tapSlop → tap
Parameters
| This page | Mature libraries | |
|---|---|---|
| threshold | 8 px | momentum-slider axisThreshold: 8Too small turns jitter into drags; too large makes drags feel late. |
| bias | 1.2 | momentum-slider horizontalBias: 1.2Above 1 creates a dead zone so diagonal swipes are not misread. |
| tapSlop | 6 px | momentum-slider clickThreshold: 5 |
| touch-action | pan-y | Horizontal carousels set touch-action: pan-y, leaving vertical scroll to the browser |
Doing it well
- Measure movement from the lock point, not the press point; otherwise the element leaps by the full threshold on the frame it crosses it. use-gesture records the threshold at trigger time in
_stepand subtracts it.@use-gesture core/src/engines/Engine.ts:274–289 - Lock once. Keep the decided axis for the whole gesture; re-deciding every frame makes a drag flip between axes halfway through.
- Really let vertical gestures go: use
touch-action: pan-yor skippreventDefaultso the page scrolls normally. Take over only once the axis is locked horizontal. - Decide a tap from the maximum distance travelled during the gesture, not the release position — dragging away and back is not a tap.
- End cleanly for mouse, touch and
pointercancel: a cancel must not fire a tap or a throw.
In our cases
42 studies are draggable, 24 have a separate tap slop, and only 5 lock an axis explicitly. momentum-slider is the complete example: an 8 px threshold, 1.2 bias, a 5 px tap slop and touch-action: pan-y.
Used in 42 cases
Reference implementation · basics.js
/**
* Decide what a pointer gesture is: a tap, a horizontal drag, or a vertical one.
* Nothing is committed until the pointer has moved `threshold` px; then the dominant
* axis wins if it beats the other by `bias`. Movement is reported from the lock point,
* so the element does not jump by the threshold.
*/
export function createGestureArbiter({ threshold = 8, bias = 1.2, axis = 'auto', tapSlop = 6 } = {}) {
let start = null, lock = null, origin = null, maxDist = 0;
return {
down(x, y) { start = { x, y }; lock = null; origin = null; maxDist = 0; },
move(x, y) {
if (!start) return { state: 'idle' };
const dx = x - start.x, dy = y - start.y;
maxDist = Math.max(maxDist, Math.hypot(dx, dy));
if (!lock) {
if (Math.hypot(dx, dy) < threshold) return { state: 'pending', dx, dy };
const ax = Math.abs(dx), ay = Math.abs(dy);
lock = axis !== 'auto' ? axis : ax > ay * bias ? 'x' : ay > ax * bias ? 'y' : 'free';
origin = { x, y };
}
return { state: 'dragging', axis: lock,
dx: lock === 'y' ? 0 : x - origin.x, dy: lock === 'x' ? 0 : y - origin.y };
},
up() {
const result = { tap: !!start && maxDist < tapSlop, axis: lock };
start = null; lock = null;
return result;
},
};
}