Drag with a Dead Zone and Pointer Capture
A deliberate click moves the pointer one to three pixels; a tap on a phone moves five or more. A drag handler with no dead zone calls all of them drags, so the track lurches on every click and the code that suppresses the post-drag click starts suppressing clicks the user meant. This hook captures the pointer on `pointerdown` — so a fast throw that leaves the element keeps delivering moves — and only commits to a drag once the pointer is further than the threshold from the press point. Every one of the four ways a gesture can end funnels through a single exit, because the path you forget is the one that leaves the track welded to a pointer that is no longer there.
Drag with a Dead Zone and Pointer Capture
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/drag-deadzone-capture.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| threshold | number | = 8 | Dead-zone radius in pixels for mouse and pen input. |
| touchThreshold | number | = 16 | Dead-zone radius for touch, which needs roughly double. |
| axis | 'both' | 'x' | 'y' | = 'both' | A single axis adds a directional lock and narrows touch-action to the other one. |
| primaryOnly | boolean | = true | Ignore secondary touches and any mouse button but the left one. |
| manageStyles | boolean | = true | Set touch-action and user-select on the element, restoring inline values on cleanup. |
| enabled | boolean | = true | Detach every listener when false. Any live capture is released as it detaches. |
| onPress | (state: DragDeadzoneState) => void | — | An accepted pointerdown, before any commitment. Kill in-flight settle tweens here. |
| onDragStart | (state: DragDeadzoneState) => void | — | The dead zone was cleared. At most once per gesture. |
| onDrag | (state: DragDeadzoneState) => void | — | Every move after commitment. deltaX/deltaY are re-based to the commit point. |
| onDragEnd | (state: DragDeadzoneState, end: DragEnd) => void | — | The single exit. moved: false means treat the gesture as a click. |
How it works
The technique in order, not the code in order.
pointerdownrecords the press point and callssetPointerCapture(e.pointerId)immediately, before any distance test. Capture first, commit second: at the moment the threshold is crossed the pointer may already be outside the element, andsetPointerCaptureon a pointer that has since ended throwsNotFoundError. Inside its ownpointerdownthe pointer is guaranteed active, so it cannot throw there.pointermovecomputesdistance = Math.hypot(clientX - startX, clientY - startY)and returns early while it is under the threshold — 8 px for mouse and pen, 16 px for touch, read frompointerTypeon the live pointer rather than from a media query. Until it commits, no callback fires and nothing moves.At the moment of commitment the delta origin is re-based to the current point (
lastX = clientX). A consumer that integratesdeltaXtherefore starts from zero at the commit point instead of receiving the entire dead zone as the first frame's delta, which would snap the track 8 px the instant it engaged.dxstill measures from the true press point, so the distance maths stays honest.With
axisset toxorythe threshold test becomes directional: if the cross axis clears the dead zone first *and* dominates the main axis, the gesture is abandoned as cancelled so the browser can scroll. Combined withtouch-action: pan-y, a horizontal dragger and vertical page scrolling coexist on a phone.One
finish(clientX, clientY, cancelled)handlespointerup,pointercancel,lostpointercaptureand the returnedcancel(). It releases the capture if still held, nullspointerId, and callsonDragEnd(state, { moved, cancelled }). ThepointerId === nullguard at the top makes the second arrival for one gesture a no-op —lostpointercapturealways follows a release, so without it every drag would end twice.The effect sets
touch-action(fromaxis) anduser-select: noneon the element and restores the previous inline values on cleanup, and it cancels HTML5dragstartso a press on an image does not turn into a native file drag.movedis the whole output that matters downstream: false means the gesture was a tap and the consumer should treat it as a click; true means a click that arrives next should be swallowed.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| THRESHOLD | = 8 | Dead-zone radius in pixels for mouse and pen. A deliberate trackpad click slips one to three pixels, and a click while the hand is moving toward the target slips more, so below about 6 the dead zone stops catching real clicks. Above about 14 the beginning of a genuine drag feels glued down — the pointer has visibly moved and the track has not. |
| TOUCH_THRESHOLD | = 16 | Dead-zone radius for touch. A finger pad rolls as it presses and the reported point moves with it, typically 5-10 px on a tap; 8 px would classify a large share of taps as drags. Chrome's own touch-slop for scroll arbitration is in the same range, so this roughly matches what the platform already considers a tap. |
| touch-action (axis 'x') | = 'pan-y' | Not none. none gives the element every gesture, including the vertical swipe that was meant to scroll the page, and a horizontal carousel that eats vertical swipes makes a phone page feel broken. pan-y hands vertical panning back to the browser, which then fires pointercancel when it claims the gesture — which is exactly why pointercancel has to be handled. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Nothing changes, and that is the correct behaviour rather than an omission. Every position this hook reports is one the user is currently holding under their own finger — direct manipulation, one to one, with no animation of its own to shorten or remove, which is why the reduced-motion guidance explicitly exempts it. Reduced motion belongs to whatever happens after release: the settle, the snap, the momentum. Note that the dead zone is deliberately not widened either; a tremor threshold and a motion preference are different user needs and conflating them would degrade dragging for people who never asked for it.
The cleanup releases the pointer capture first, while pointerId still records which pointer holds it, then nulls the gesture state, removes all six listeners (pointerdown, pointermove, pointerup, pointercancel, lostpointercapture, dragstart), neuters the cancel() closure, and restores the element's previous inline touch-action and user-select. There is no timer and no rAF. Under React 19 StrictMode the first effect has fully released its capture before the second attaches.
Press and release without moving: the row does not shift by even a pixel and the readout says the gesture was a tap. Press, wobble a few pixels inside the dead-zone ring, release: still a tap, and the ring never fills. Push past the ring and the row starts following the pointer from where it committed, with no initial jump. Drag fast off the side of the panel and keep going — the row keeps tracking, because the capture holds. Release outside the panel and the gesture ends there. On a phone, swipe horizontally and the row moves; swipe vertically and the page scrolls while the readout reports a cancelled gesture. Right-click on the row: nothing starts. Drag with a second finger already down: the first gesture keeps control. Unmount mid-drag and the rest of the page still responds to the mouse.
Pitfalls
- No dead zone at all. Every click becomes a two-pixel drag, so the track twitches on each press, and any click suppression built on "did it drag" now eats clicks the user intended. This is the failure that makes drag carousels feel untrustworthy.
- Capturing the pointer at the moment of commitment rather than on press. Two bugs at once: the moves that happened during the dead zone are lost, and if the pointer has already ended,
setPointerCapturethrowsNotFoundErrorand the drag silently never starts. - No pointer capture. Throw the track quickly and the pointer leaves the element;
pointermovestops arriving, the track freezes mid-throw with the button still down, and the eventualpointeruplands somewhere else entirely, so the gesture never ends. - Handling only
pointerup.pointercancelfires whenever the browser claims the gesture for scrolling or a long-press menu — routine on touch — andlostpointercapturefires when the capture is dropped without a release. Miss either anddraggingstays true forever: the nextpointermovefrom an unrelated hover keeps driving the track. - Not guarding against the second end event.
lostpointercapturealways follows a release, so a naive implementation runs its end logic twice per drag — two snap tweens, two velocity readings, and a click-swallow flag set and then cleared. - Ending the drag without re-basing the delta origin at commitment. The first
onDragthen reports the full dead-zone offset, and the track jumps by the threshold the moment it engages — small, but it is exactly the wrong 8 px because it lands on the frame the user is watching for feedback. - Overwriting
pointerIdwhen a second finger lands. The first pointer's capture is then never released, so the element holds a capture for a pointer that has ended and the next gesture on it behaves unpredictably. - Forgetting
touch-action. On touch the browser arbitrates the gesture, decides it is a pan, and firespointercancelbefore the dead zone is even cleared. The drag works perfectly on a desktop and does nothing at all on a phone, which is a hard bug to find without a device. - Setting
touch-action: noneon a horizontal dragger. The element swallows vertical swipes too, so the page cannot be scrolled by dragging anywhere over the carousel — on a phone that is often most of the screen. - Unmounting while the capture is held. The browser keeps routing pointer events to a detached node, and the pointer stays captured until the next
pointerdownanywhere in the document, so the rest of the page appears to ignore the mouse. - Using cumulative path length instead of straight-line distance for the dead zone. A hand tremor that wobbles back and forth over the same three pixels accumulates past any threshold without the pointer ever leaving the press point, so the click is classified as a drag — the tremor case the dead zone existed to catch.
- Calling
preventDefault()onpointerdownto stop text selection. It also prevents focus, so the element can no longer be reached or activated from the keyboard. Settinguser-select: nonecosts nothing and breaks nothing.
Notes
- Pairs with
capture-phase-click-swallow: this hook decides whether the gesture moved, that one suppresses the resulting click. They are separate because the swallow has its own edge cases — one click and no more, and keyboard activation must pass through — and because plenty of consumers want the classification without wanting to intercept clicks at all. - Both
distanceandtravelare reported.distanceis what the dead zone tests;travelis the path integral, and their ratio is a cheap measure of how straight the gesture was, which is useful if you want to distinguish a flick from a scribble. - The returned
cancel()exists so a host can abort a gesture on Escape, on a route change, or when a modal opens. It runs the same single exit, so the capture is released andonDragEndreportscancelled: true. - Momentum, snapping and rubber-banding are deliberately absent. This is the input layer; what the position does after release belongs to whatever integrates the deltas.