useDragScroll
Grab-to-scroll a container with pointer drags, momentum glide, and click suppression after a drag.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-drag-scroll.jsonPrompt
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 (includingclick) to the container and silently kills every clickable child. - Click suppression after a drag — the browser still emits a
clickwhen a drag ends; pointer capture retargets it to the container in Chromium (so a container-levelonClicksuch 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 anevent.timeStampwindow — 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.timeStampsamples, then decays asv *= 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 lock —
grabbing+user-select: nonelive ondocument.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-actionis never set: the platform's own drag-with-inertia (and its rubber-banding) beats anything the hook could emulate, andaxis: "x"therefore leaves vertical page scrolling untouched.