Hooks

useSwipe

A pointer-event swipe hook with direction locking, distance-or-velocity commit, and live follow-the-finger state.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export type SwipeDirection = "left" | "right" | "up" | "down"
export type SwipeAxis = "x" | "y"
/** which axis this gesture takes over from the browser (decides the `touchAction` value). */
export type SwipePreventScroll = "horizontal" | "vertical" | "none"

export interface SwipeInfo {
  /** current direction along the locked axis (falls back to the direction at lock time when the delta is 0). */
  direction: SwipeDirection
  /** the axis this gesture locked, fixed for the rest of it. */
  axis: SwipeAxis

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-swipe.json

Prompt

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 directions skips 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 outcomedeltaX/deltaY/distance are continuous values for animation (committed at most once per frame); onSwipe is 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 under prefers-reduced-motion leaves the gesture itself intact.
  • Pointer capture — after the lock, setPointerCapture pins subsequent move/up to the same element, so the finger leaving the card's box never loses events; unmount releases it guarded by isConnected + hasPointerCapture.
  • touch-action is the consumer's job — the hook only hands out handlers and never sees the element, so it computes the value, returns it as touchAction, and the consumer must write it into style. 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 componentsmedia/carousel (embla) and feedback/drawer each 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.

On This Page