Hooks

useDragScroll

Grab-to-scroll a container with pointer drags, momentum glide, and click suppression after a drag.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export type DragScrollAxis = "x" | "y" | "both"

export interface UseDragScrollOptions {
  /** Which axes can be dragged. `"x"` hands vertical scrolling back to the page/container. Default `"both"`. */
  axis?: DragScrollAxis
  /** Turn off the grab interaction (the container stays a plain native scroll area: wheel / trackpad / scrollbar all work). Default false. */
  disabled?: boolean
  /** Glide on after release, using the velocity of the last few frames. Disabled automatically under `prefers-reduced-motion`. Default true. */
  momentum?: boolean
  /** Let the hook supply the `grab` / `grabbing` cursor through `bind.style`. Default true. */

Installation

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

Prompt

Build a React + TypeScript "useDragScroll" hook (React only, no extra dependencies).

Contract
- `useDragScroll<T extends HTMLElement = HTMLElement>(options?): { ref, isDragging, bind }`.
- Options: `axis?: "x" | "y" | "both"` (default `"both"`), `disabled?: boolean`
  (default false), `momentum?: boolean` (default true), `cursor?: boolean`
  (default true), `onScrollStart?: () => void`, `onScrollEnd?: () => void`.
- Returns `ref` — a `RefObject<T | null>` for the scrolling container itself (the
  `overflow-auto` element, not a wrapper); `isDragging` — pointer is down AND past
  the drag threshold (false during momentum glide, the hand is off by then);
  `bind` — props to spread on that same element: `{ style: { cursor }, "data-dragging":
  "true" | undefined, onDragStart }`. `bind.style` carries only the cursor, so
  consumers spread `bind` before their own `style` or merge `bind.style` into it.
- `onScrollStart` / `onScrollEnd` are exactly paired, once per scroll activity:
  start fires when a press turns into a real drag, end fires when everything has
  settled (release, plus the momentum glide if one ran). A press that never passes
  the threshold fires neither. A new press that interrupts a glide does NOT close
  the activity — it stays open until that new gesture settles, so rapid flicking
  does not spam start/end pairs.

Behavior
- Pointer Events only, mouse and pen: `pointerType === "touch"` returns
  immediately, because native touch scrolling already is drag-with-inertia and
  does it better. For the same reason never set `touch-action` — with `axis: "x"`
  vertical scrolling must stay entirely with the page.
- Only primary-button (`event.button === 0 && event.isPrimary`) presses count.
- On pointerdown the hook does NOT capture the pointer, change styles or
  `preventDefault()` — it only records the anchor (pointer coords + current
  `scrollLeft`/`scrollTop`) and attaches `pointermove`/`pointerup`/`pointercancel`
  on `window` (the pointer can be released outside the element before capture
  exists). Capturing on pointerdown would retarget the compatibility mouse events
  — including `click` — onto the container, and cards inside it would never be
  clickable again. So `setPointerCapture` is deferred until the gesture is a
  confirmed drag.
- A press becomes a drag only after moving more than 5px measured ONLY on axes
  that are both enabled and actually overflowing: with `axis: "x"` a vertical
  wobble while clicking must not start a drag nor eat the click. Axis overflow is
  re-measured on every pointerdown (`scrollWidth - clientWidth > 1`); if no
  enabled axis overflows, the hook does not take the gesture at all.
- At that threshold, in one step: claim the `pointerId` in a module-level set,
  `setPointerCapture`, set `cursor: grabbing` + `user-select: none` (both plain
  and `-webkit-` for Safari) on `document.body`, clear any non-collapsed
  selection, and fire `onScrollStart`. The module-level claim means nested drag
  containers (a board that scrolls horizontally, columns that scroll vertically)
  share one pointer safely: whoever crosses ITS OWN axis threshold first owns the
  gesture, so horizontal flicks go to the board and vertical ones to the column.
- Body-level styles are reference-counted at module scope: save the original
  values on the first lock, restore only when the count returns to zero — a second
  instance must never record another instance's `grabbing` as the "original" and
  leave the page stuck with it.
- Scroll follows the anchor, not per-event deltas: `next = anchorScroll -
  (client - anchorClient)`, clamped to `[0, max]`. When the clamp bites, re-anchor
  to the current pointer position so reversing direction moves immediately instead
  of first burning off the over-drag.
- Momentum: push `{ x, y, t: event.timeStamp }` samples (keep the last 6) on every
  move; never `Date.now()`. On release, take the samples within the last 100ms,
  velocity = -(Δposition / Δt) in px/ms (negated: pointer right means scrollLeft
  down), clamped to ±4px/ms. Below 0.1px/ms treat it as a stop, not a fling
  (holding still before releasing must not drift). Otherwise glide with
  `requestAnimationFrame`: per frame `dt = clamp(now - lastFrame, 1, 32)`,
  `v *= 0.92 ** (dt / 16.7)` so friction is frame-rate independent, accumulate
  float positions and write them to `scrollLeft`/`scrollTop`, zero the velocity of
  any axis that hits its bound, stop under 0.05px/ms and then fire `onScrollEnd`.
  The rAF timestamp and `event.timeStamp` share one time base, so the first frame's
  dt is honest. A new pointerdown cancels the frame immediately — a press must stop
  the fling dead. Those two floors matter: 0.92 puts the time constant near 200ms
  (glide distance ≈ velocity × 200px, worst case ~0.9s), and cutting off at
  0.05px/ms (≈0.8px per frame) removes a long invisible tail that would otherwise
  keep the gesture "unfinished" for another half second.
- `prefers-reduced-motion: reduce` (read via `matchMedia` at release time, never
  during render) skips the glide entirely; dragging itself is unaffected. Same
  path as `momentum: false`.
- Click suppression, the failure mode this hook exists to get right: after a drag
  the browser still emits a `click`. Where it lands depends on the engine — while
  the pointer is captured the compatibility mouse events are retargeted, so in
  Chromium that click arrives on the container itself (verified: its target is the
  scroll div, not the pressed card), which fires any `onClick` the consumer put on
  the container ("click the map to drop a pin"); engines that keep the original
  target fire the card instead. Both are wrong after a drag, so swallow it: keep a
  permanent CAPTURE-phase `click` listener on the container and, on
  release-after-drag, arm a window of `event.timeStamp + 400`. The next click
  inside that window is swallowed with `stopPropagation()` + `preventDefault()` and
  the window is disarmed (one drag eats at most one click, so the user's next real
  click still works). Because React delegates events at the app root, stopping
  propagation at the container in the capture phase is what keeps both container
  and child `onClick` handlers from firing. Compare timestamps rather than using a
  `setTimeout` guard — there is no race about whether the timer or the click
  arrives first.
- While a drag is live, `bind.onDragStart` calls `preventDefault()` so pressing an
  image or link inside the container starts a scroll, not a native HTML5 drag.
- Presses starting on `input, textarea, select, [contenteditable], and
  [data-drag-scroll-ignore]` are ignored, so text selection and native dropdowns
  inside the container keep working.
- The container's inline `scroll-behavior` is borrowed (`auto`) for the duration
  of an activity and restored afterwards: a consumer's `scroll-smooth` would turn
  every scroll write into a tween and make the drag feel glued.
- Cleanup covers everything: pending rAF, the window listeners, the pointer
  capture, the body cursor/selection lock, the borrowed `scroll-behavior` — on
  gesture end, on option change and on unmount, so a component that unmounts
  mid-drag cannot leave the page stuck in `grabbing`.
- State the two honest limits rather than pretending they don't exist. (1) `ref` is
  a `RefObject`, so listeners bind on mount: a container that gets swapped
  underneath the hook (conditional branch or `key` change in a child) does not
  re-bind — remount the component that owns the hook, or switch the recipe to a
  callback ref. (2) The grab cursor is refreshed by a `ResizeObserver` on the
  container and its first element child, so content appended directly into the
  container with no wrapper element can leave a stale cursor until the next resize;
  the drag itself is unaffected because overflow is re-measured on every
  pointerdown.

Rendering & styling
- The hook renders nothing. It writes exactly three things: `scrollLeft`/
  `scrollTop`, `document.body`'s cursor + user-select during a drag, and the
  container's inline `scroll-behavior` during an activity.
- `bind.style.cursor` is `"grab"` / `"grabbing"` only while `cursor` is on, the
  hook is enabled, AND the content really overflows an enabled axis — tracked with
  a `ResizeObserver` on the container plus its first element child (its `observe()`
  callback doubles as the first measurement, so no setState in an effect body).
  Nothing else is styled; consumers own the container's tokens (`bg-card`,
  `border`, `focus-visible:ring-ring`) and can key off
  `data-[dragging=true]:` for drag-state styling.
- Accessibility: the container stays a native scroll box, so give it `tabIndex={0}`
  and a label and arrow/PageUp/PageDown keys keep working; drag is an addition for
  pointer users, never the only way to reach the content.

Customization levers
- Threshold and feel: the 5px drag slop, the 400ms click-suppress window, the
  0.92 per-frame friction, the 0.1px/ms fling floor and the ±4px/ms clamp are
  module constants — raise friction toward 0.97 for a long ice-like glide, drop it
  to 0.85 for a short one, raise the slop on pen-heavy surfaces.
- Axis lock: `axis` restricts which axes the drag writes; keep it `"x"` for strips
  so the page keeps its vertical scroll, `"both"` for canvases.
- Opting children out: extend the ignore selector (or mark nodes with
  `data-drag-scroll-ignore`) for sliders, resize handles and anything else that
  owns its own pointer gesture.
- Snap-aware variant: if the container uses CSS scroll-snap, disable momentum and
  let `onScrollEnd` trigger your own snap-to-nearest — the glide and snapping
  otherwise fight over the same scroll offset.
- Wheel/keyboard behavior is intentionally untouched; if you want a drag-only pane
  the consumer adds `overflow-hidden` plus this hook, not an option here.

Concepts

  • Drag slop before pointer capture — pointerdown is left completely alone; only 5px of movement on an axis that can actually scroll promotes the press to a drag and calls setPointerCapture. Capturing earlier retargets the compatibility mouse events (including click) to the container and silently kills every clickable child.
  • Click suppression after a drag — the browser still emits a click when a drag ends; pointer capture retargets it to the container in Chromium (so a container-level onClick such as "click the map to drop a pin" fires), and other engines can leave it on the pressed card. A capture-phase listener on the container swallows exactly one click inside an event.timeStamp window — no timer, so there is no ordering race — and since React delegates at the app root, stopping propagation there is what keeps both container and child handlers quiet.
  • Anchor-with-re-anchor — the offset is always computed from the press anchor (no per-event delta drift), and the anchor is reset whenever the scroll clamps at an edge, so reversing direction moves the content instantly instead of first paying back the over-drag.
  • Frame-rate-independent glide — release velocity comes from the last 100ms of event.timeStamp samples, then decays as v *= 0.92 ** (dt / 16.7) inside rAF, so a 120Hz screen glides the same distance as a 60Hz one; the glide is cut off at 0.05px/ms so it does not trail an invisible half-second tail, and a new press cancels the frame immediately.
  • Per-axis pointer claim — a module-level set of claimed pointerIds lets nested drag containers coexist: the first one to cross its own axis threshold owns the gesture, so a horizontal flick pans the board while a vertical one scrolls the column under the cursor.
  • Reference-counted body lockgrabbing + user-select: none live on document.body (the cursor must survive leaving the container) with a module-level count, so two instances can't record each other's override as the "original" value and strand the page in a grabbing cursor.
  • Touch is passthrough, not polyfill — touch pointers are returned on immediately and touch-action is never set: the platform's own drag-with-inertia (and its rubber-banding) beats anything the hook could emulate, and axis: "x" therefore leaves vertical page scrolling untouched.

On This Page