useSwipe
A pointer-event swipe hook with direction locking, distance-or-velocity commit, and live follow-the-finger state.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-swipe.jsonPrompt
Build a React + TypeScript "useSwipe" hook (React only, no gesture library).
Contract
- useSwipe(options?: {
onSwipe?: (direction: "left" | "right" | "up" | "down", info: SwipeInfo) => void
onSwipeStart?: (info: SwipeInfo) => void
onSwipeMove?: (info: SwipeInfo) => void
onSwipeEnd?: (info: SwipeInfo & { committed: boolean; cancelled: boolean }) => void
threshold?: number // px, default 50, clamped to >= 1
velocityThreshold?: number // px/ms, default 0.3, clamped to >= 0.01
directions?: ("left"|"right"|"up"|"down")[] // default all four
preventScrollOn?: "horizontal" | "vertical" | "none" // default "none"
disabled?: boolean
})
- Returns { swipeProps, touchAction, isSwiping, direction, deltaX, deltaY, distance }.
swipeProps holds onPointerDown / onPointerMove / onPointerUp / onPointerCancel and
is spread onto the element that should be swipeable; touchAction is the CSS value
the consumer must apply itself (style={{ touchAction }}) because the hook never
receives the element.
- SwipeInfo = { direction, axis: "x" | "y", deltaX, deltaY, distance, velocity,
duration }. deltaX/deltaY are zero on the axis that was NOT locked; distance and
velocity are the absolute values along the locked axis; duration comes from event
timestamps.
Behavior
- One code path for touch, mouse and pen: Pointer Events only, no touch* + mouse*
duplication. Ignore non-primary mouse buttons.
- Direction locking. A press does nothing until the pointer travels 8px; at that
moment pick the axis with the larger absolute delta, derive the direction from its
sign, and lock both for the rest of the gesture — the cross-axis component is then
reported as 0 and never re-evaluated. Without the lock, page scrolling and a
horizontal swipe fight over the same gesture.
- If the locked-in direction is not listed in `directions`, abandon the whole press:
no pointer capture, no preventDefault, no state — the gesture is handed back to
the page so normal scrolling still happens. So directions: ["left"] means a
rightward start produces zero follow movement at all.
- On lock, call setPointerCapture(pointerId) so moves and the release keep arriving
even after the finger leaves the element's box; release it on up/cancel/unmount.
- Commit rule: fire onSwipe once, on release, when distance >= threshold OR
|velocity| >= velocityThreshold. The velocity branch is what makes a fast, short
flick page the deck — distance alone feels broken. When velocity passes, it also
decides the direction (dragging far one way then flicking back reads as a
take-back). Never fire onSwipe more than once per gesture, and never fire it after
a pointercancel.
- Velocity is a lightly smoothed per-move sample (v = 0.3*v + 0.7*sample) computed
from event.timeStamp — never Date.now(), never a render-phase clock read. Seed it
with the press-to-lock segment instead of 0: a flick fast enough to produce a
single pointermove locks on that very event, and starting from 0 would leave that
gesture with velocity 0 forever (a 20px fast flick would silently do nothing). If
the last move is older than 100ms when the pointer is released, treat velocity as
0, otherwise "drag, hold still, let go" is misread as a flick.
- Live state is committed at most once per animation frame (requestAnimationFrame
coalescing) together with onSwipeMove, so consumers get one coherent value per
frame instead of ~120 setState calls per second. On release the deltas go back to
0, which is what makes a consumer's CSS transition spring the element back.
- onSwipeStart / onSwipeEnd bracket only gestures that actually locked. onSwipeEnd
always fires for those, with committed and cancelled flags, so the consumer can
settle its animation in one place.
- All callbacks are read through a latest-ref updated after every render and appear
in no dependency array — inline arrow callbacks must not rebuild the handlers.
- preventScrollOn only decides the returned touchAction ("horizontal" -> pan-y,
"vertical" -> pan-x, "none" -> auto, and always auto while disabled). It also
gates preventDefault(), which is called only after locking and only when the
locked axis is the one the consumer claimed — on touch devices it is touch-action,
not preventDefault, that stops the browser from panning.
- Clamp hostile numbers: threshold <= 0 or NaN would make every tap a swipe.
- Cleanup: cancel the pending frame and release pointer capture on unmount, guarded
by isConnected + hasPointerCapture.
Rendering & styling
- The hook renders nothing. Consumers own the visuals: transform:
translate3d(deltaX px, 0, 0) while isSwiping, no transition during the gesture and
a short transition afterwards so the element springs back, plus semantic tokens
only (bg-card, border, text-muted-foreground, bg-destructive/10 for a destructive
reveal) and motion-reduce:transition-none so reduced-motion users keep the gesture
and lose only the spring.
- The swipeable surface wants select-none plus cursor-grab / cursor-grabbing, and
style={{ touchAction }} from the hook.
Customization levers
- threshold / velocityThreshold are the whole feel: raise threshold for destructive
actions (harder to trigger by accident), lower velocityThreshold to make light
flicks count. Set velocityThreshold very high to get distance-only commits.
- directions narrows what is grabbed at all: ["left"] for archive-on-left rows,
["down"] for a dismissible sheet, ["left","right"] for a card deck. Passing an
empty array makes the hook inert, same as disabled.
- preventScrollOn is per-surface: "horizontal" for a card deck inside a vertically
scrolling page, "vertical" for a pull-to-dismiss sheet, "none" when the gesture
must coexist with native panning on both axes.
- Follow-movement shaping stays in the consumer: clamp the offset (Math.min(0,
deltaX) for a reveal), damp it at the ends (deltaX * 0.35), or add
rotate(deltaX/40 deg) for a card-deck feel — the hook deliberately reports raw
locked-axis pixels.
- Wire onSwipeMove instead of the returned state if you animate imperatively (a
motion value / direct style write) and want zero re-renders per frame.
- A swipe is never an accessibility equivalent: keyboard and screen-reader users
cannot perform it. Anything only reachable by swiping (delete, archive, dismiss,
paging) needs a real button or key binding alongside it — the hook stays
pointer-only on purpose instead of guessing a keyboard mapping.
- Multi-touch pinch/rotate, momentum after release, and cross-axis diagonal
gestures are intentionally out of scope: one pointer, one axis, one discrete
outcome.Concepts
- Direction locking — the direction is decided only past 8px, and that axis is then fixed for the rest of the gesture with the cross axis reported as 0. Without the lock, page scrolling and a horizontal swipe fight over the same gesture; a start outside
directionsskips even the pointer capture and hands the gesture back to the page. - Distance-or-velocity commit — the rule is "travelled far enough or moving fast enough". Distance alone kills the fast flick (the finger is gone after 20px), velocity alone kills the slow drag all the way out; only both together feel right. When velocity passes it also decides the direction, so "drag out, flick back" reads as a take-back. Velocity is seeded from the press-to-lock segment: a very fast flick may produce a single
pointermove, and starting from 0 would leave it at velocity 0 for its whole life. - Follow-the-finger state vs. discrete outcome —
deltaX/deltaY/distanceare continuous values for animation (committed at most once per frame);onSwipeis the one-shot discrete conclusion for your logic. Deltas return to 0 on release, which is why the spring-back is entirely the consumer's CSS transition — dropping that transition underprefers-reduced-motionleaves the gesture itself intact. - Pointer capture — after the lock,
setPointerCapturepins subsequent move/up to the same element, so the finger leaving the card's box never loses events; unmount releases it guarded byisConnected+hasPointerCapture. touch-actionis the consumer's job — the hook only hands out handlers and never sees the element, so it computes the value, returns it astouchAction, and the consumer must write it intostyle. On touch devices that CSS property is what stops the browser from panning;preventDefault()on pointermove only blocks selection and native drag.- Division of labour with finished components —
media/carousel(embla) andfeedback/drawereach ship their own drag logic; do not stack this hook on top of them. This hook is the gesture layer for cards, rows and panels you wrote yourself.
useLongPress
A press-and-hold gesture hook — pointer-captured, move-tolerant, keyboard-reachable, with 0–1 progress for your own ring.
useResizeObserver
A callback-ref hook that measures an element's own size with ResizeObserver — selectable box model, rAF-batched or debounced commits, and no resize-loop errors.