Skip to content
Kinetic
ScrollDOM5 files

Drag-Throw Scroll

A third input modality bolted onto a scroll position that already has two. Wheel, pointer drag and arrow keys all write to one place, so they cannot disagree — press stops whatever was coasting exactly where it looks, the drag maps 1:1 from the press point, and release converts the last 100 ms of hand movement into a decaying throw. Pointed at the document it drives the shared Lenis instance; pointed at a box it drives `scrollTop` through the same physics.

lenispointer-eventsinertiadragvelocity

Live preview

The real component, running in this page.

02

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-throw-scroll.json
pulls inlenis@^1.3.11

Also installs use-lenis, use-raf, use-reduced-motion, damp from this registry.

03

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.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Drag-Throw Scroll

A third input modality bolted onto a scroll position that already has two. Wheel, pointer drag and arrow keys all write to one place, so they cannot disagree — press stops whatever was coasting exactly where it looks, the drag maps 1:1 from the press point, and release converts the last 100 ms of hand movement into a decaying throw. Pointed at the document it drives the shared Lenis instance; pointed at a box it drives `scrollTop` through the same physics.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

Grab the page and pull it; let go mid-move and it coasts on, decaying, as far as the flick earned.

- Category: scroll
- Runtime cost: dom
- Dependencies: lenis@^1.3.11

## How it works

1. On `pointerdown` the surface calls `setPointerCapture` and records `startY` plus `startScroll = lenis.scroll` — the animated position, not `targetScroll`, so grabbing a coasting page stops the content where the eye sees it rather than where the throw was headed.
2. Every `pointermove` recomputes the scroll target absolutely: `clamp(startScroll - (clientY - startY) * gain, 0, limit)`. Gain is `multiplier` for mouse and pen and hard-wired to 1 for touch, because a finger is physically on the content and any other value slides the content out from under it.
3. Positions are pushed into a 5-entry ring of `{ y, timeStamp }`. On release, velocity is `(newest.y - oldest.y) / (newest.t - oldest.t) * 1000` px/s using only samples inside a 100 ms window, so the number describes the hand rather than the event rate.
4. The throw target is `target + (-velocity * gain) * throwSeconds`, clamped to the scroll limit. Below `MIN_THROW_SPEED` nothing is issued at all, so a careful release parks instead of drifting.
5. The decay is one curve expressed twice: `lenis.scrollTo(y, { lerp: 0.05 })` for the page, and `damp(scrollTop, y, perFrameToSmoothing(0.05), dt)` on the shared rAF for a box scroller, which stops writing once it is within 0.5 px of target.
6. `ArrowUp` / `ArrowDown` on the focused surface step by `keyStep` px through `scrollTo(y, { duration: 0.2 })`, so keyboard, wheel and hand all mutate the same single position.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `multiplier = 2`
  Scroll px per mouse px. At 1 a long page needs a grab per viewport, because the cursor runs out of window; above ~3 the content outruns the hand and the gesture stops reading as direct manipulation. Touch ignores this entirely and uses 1.
- `throwSeconds = 0.65`
  Coast distance is velocity × this, so it is the time constant of the flick. Under ~0.3 a hard flick barely outruns the release and the throw is invisible; over ~1 a flick crosses the whole document and lands somewhere the user did not aim.
- `THROW_LERP = 0.05`
  Per-frame fraction of the remaining gap, i.e. a long slow settle — the point of a throw is that it decays visibly. At 0.2 it arrives in a few frames and reads as a jump; `perFrameToSmoothing` converts the same number for the `damp` path so the two sinks decay identically.
- `SAMPLE_WINDOW_MS = 100`
  Only samples newer than this count toward the release velocity. Without the window, a drag that travels fast and then stops dead for a second still throws, because the fast samples are still in the buffer — the page launches from a hand that was holding still.
- `SAMPLE_COUNT = 5`
  Ring length. One sample is pure jitter, and more than about five spans long enough that the window filter is doing all the work anyway; five covers roughly 80 ms at 60 Hz, which is one flick.
- `MIN_THROW_SPEED = 120 px/s`
  Below this no throw is issued. At 0.65 s of coast, 120 px/s is a 78 px slide — around the point where a deliberate stop stops feeling deliberate. Lower and the content creeps after every release.
- `SETTLE_EPSILON = 0.5 px`
  An exponential never actually arrives. Without a floor the box sink writes a sub-pixel `scrollTop` every frame forever, so the rAF subscription never idles and the scroll event never stops firing.
- `KEY_STEP_SECONDS = 0.2`
  Arrow-key travel time. Instant is disorienting because nothing connects the two positions; beyond ~0.35 s holding the key queues steps faster than they complete and the page falls behind the keyboard.

## API

```ts
function DragThrowScroll(props: { children?: ReactNode; scroller?: RefObject<HTMLElement | null>; multiplier?: number; throwSeconds?: number; keyStep?: number; enabled?: boolean; className?: string }): JSX.Element
```

- `children: ReactNode`
  The draggable content. The surface is `position: relative` and nothing else.
- `scroller: RefObject<HTMLElement | null>`
  Box to scroll. Omit to drive the document through the shared Lenis instance.
- `multiplier: number` — default `2`
  Scroll px per pointer px for mouse and pen. Touch is always 1.
- `throwSeconds: number` — default `0.65`
  Time constant of the release throw; distance is velocity × this.
- `keyStep: number` — default `100`
  Arrow-key step in px while the surface has focus.
- `enabled: boolean` — default `true`
  False removes every listener and restores native scrolling.
- `className: string`
  Merged onto the drag surface.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
/**
 * Lenis's per-frame lerp for the throw, and the same curve expressed for
 * `damp` so a box scroller decays identically to the page. One number, two
 * sinks — tuning it in one place cannot desync them.
 */
const THROW_LERP = 0.05;
const THROW_SMOOTHING = perFrameToSmoothing(THROW_LERP);

const SAMPLE_COUNT = 5;
const SAMPLE_WINDOW_MS = 100;
const MIN_THROW_SPEED = 120;
const SETTLE_EPSILON = 0.5;
const KEY_STEP_SECONDS = 0.2;

/** Presses on real controls belong to those controls, not to the drag. */
const NO_DRAG = 'a,button,input,select,textarea,label,[data-no-drag]';

function scrollBoxOf(scroller?: RefObject<HTMLElement | null>): Element | null {
  if (scroller) return scroller.current;
  return typeof document === 'undefined' ? null : document.scrollingElement;
}

export function DragThrowScroll({
  children,
  scroller,
  multiplier = 2,
  throwSeconds = 0.65,
  keyStep = 100,
  enabled = true,
  className,
}: DragThrowScrollProps) {
  const surface = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  /* Lenis owns the document and nothing else. A component pointed at its own
     box must not build one — and under reduced motion nobody wants smoothing
     at all, so the page sink degrades to direct `scrollTop` writes. The hook
     is refcounted: several of these on one page share the single instance
     rather than stacking `wheel` handlers that each call `preventDefault`. */
  const viaLenis = enabled && !reduced && scroller === undefined;
  const lenis = useLenis({ enabled: viaLenis });

  /** Coast target for the box sink. `null` means nothing is in flight. */
  const glide = useRef<number | null>(null);

  useRaf(
    (dt) => {
      const to = glide.current;
      const box = scrollBoxOf(scroller);
      if (to === null || !box) return;
      const next = damp(box.scrollTop, to, THROW_SMOOTHING, dt);
      // Snap and stop. An exponential never arrives, so without a floor this
      // writes a sub-pixel delta every frame forever and the loop never idles.
      if (Math.abs(to - next) < SETTLE_EPSILON) {
        glide.current = null;
        box.scrollTop = to;
        return;
      }
      box.scrollTop = next;
    },
    // Only the box sink needs a loop: Lenis animates its own scrollTo, and
    // reduced motion has nothing to animate.
    enabled && !reduced && scroller !== undefined,
  );

  useEffect(() => {
    const el = surface.current;
    if (!el || !enabled) return;

    let pointerId: number | null = null;
    let startY = 0;
    let startScroll = 0;
    let target = 0;
    let gain = 1;
    const samples: Sample[] = [];

    /* -- the sink: Lenis when it exists, the raw box otherwise -------------- */

    const read = (): number => {
      const l = lenis.current;
      // `scroll`, not `targetScroll`. This is read on press, and grabbing a
      // coasting page has to stop it where the content LOOKS, not where it was
      // headed — otherwise the first pixel of the drag jumps forward by
      // whatever the throw had left to run.
      if (l) return l.scroll;
      return scrollBoxOf(scroller)?.scrollTop ?? 0;
    };

    const limitOf = (): number => {
      const l = lenis.current;
      if (l) return l.limit;
      const box = scrollBoxOf(scroller);
      return box ? Math.max(0, box.scrollHeight - box.clientHeight) : 0;
    };

    /** Direct manipulation: the content is under a finger, so no smoothing. */
    const jump = (y: number) => {
      glide.current = null;
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { immediate: true });
        return;
      }
      const box = scrollBoxOf(scroller);
      if (box) box.scrollTop = y;
    };

    /** Release: an exponential decay toward the thrown target. */
    const coast = (y: number) => {
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { lerp: THROW_LERP });
        return;
      }
      glide.current = y;
    };

    const step = (y: number) => {
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { duration: KEY_STEP_SECONDS });
        return;
      }
      glide.current = null;
      scrollBoxOf(scroller)?.scrollTo({ top: y, behavior: reduced ? 'auto' : 'smooth' });
    };

    /* -- velocity ---------------------------------------------------------- */

    /**
     * Pointer velocity in px/s, measured from the oldest sample still inside
     * the window to the newest. Time-based rather than per-event, so the
     * number describes the hand rather than the display.
     */
    const velocityAt = (now: number): number => {
      let oldest: Sample | undefined;
      for (const sample of samples) {
        if (now - sample.t <= SAMPLE_WINDOW_MS) {
          oldest = sample;
          break;
        }
      }
      const newest = samples[samples.length - 1];
      if (!oldest || !newest) return 0;
      const span = newest.t - oldest.t;
      if (span <= 0) return 0;
      return ((newest.y - oldest.y) / span) * 1000;
    };

    /* -- pointer lifecycle ------------------------------------------------- */

    const onPointerDown = (e: PointerEvent) => {
      if (pointerId !== null || !e.isPrimary) return;
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      if (e.target instanceof Element && e.target.closest(NO_DRAG)) return;

      pointerId = e.pointerId;
      // A finger is physically on the content: any gain but 1 slides the
      // content out from under it. A mouse is an indirect device with a
      // window-height of travel available, so it gets the multiplier.
      gain = e.pointerType === 'touch' ? 1 : multiplier;
      startY = e.clientY;
      startScroll = read();
      target = startScroll;
      jump(startScroll);
      samples.length = 0;
      samples.push({ y: e.clientY, t: e.timeStamp });
      // Captured on press, not on some later threshold: by the time a
      // threshold would trip, the pointer may already be off the element.
      el.setPointerCapture(e.pointerId);
      el.dataset.dragging = 'true';
    };

    const onPointerMove = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      // Absolute, from the press point. Accumulating per-event deltas onto the
      // live scroll position loses whatever the sink has not applied yet, and
      // the content drifts behind the hand over a long drag.
      target = clamp(startScroll - (e.clientY - startY) * gain, 0, limitOf());
      jump(target);
      samples.push({ y: e.clientY, t: e.timeStamp });
      if (samples.length > SAMPLE_COUNT) samples.shift();
    };

    const release = (e: PointerEvent, thrown: boolean) => {
      if (e.pointerId !== pointerId) return;
      pointerId = null;
      el.dataset.dragging = 'false';
      if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);

      if (!thrown || reduced) {
        samples.length = 0;
        return;
      }
      samples.push({ y: e.clientY, t: e.timeStamp });
      const velocity = velocityAt(e.timeStamp);
      samples.length = 0;
      const scrollVelocity = -velocity * gain;
      if (Math.abs(scrollVelocity) < MIN_THROW_SPEED) return;
      coast(clamp(target + scrollVelocity * throwSeconds, 0, limitOf()));
    };

    const onPointerUp = (e: PointerEvent) => release(e, true);
    // Both of these are aborts, so neither throws. `pointercancel` is the OS
    // taking the gesture; `lostpointercapture` is the catch-all — capture
    // revoked with no `pointerup` at all, which is the one path that would
    // otherwise leave the surface stuck believing a finger is still down. It
    // also fires immediately after a normal release, where the `pointerId`
    // guard above has already made it a no-op.
    const onPointerCancel = (e: PointerEvent) => release(e, false);
    const onLostCapture = (e: PointerEvent) => release(e, false);

    // Native HTML5 drag on any image or link inside would hijack the gesture
    // and hand back a ghost image instead of a scroll.
    const onDragStart = (e: Event) => e.preventDefault();

    const onKeyDown = (e: KeyboardEvent) => {
      const dir = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
      if (dir === 0) return;
      e.preventDefault();
      step(clamp(read() + dir * keyStep, 0, limitOf()));
    };

    el.addEventListener('pointerdown', onPointerDown);
    el.addEventListener('pointermove', onPointerMove);
    el.addEventListener('pointerup', onPointerUp);
    el.addEventListener('pointercancel', onPointerCancel);
    el.addEventListener('lostpointercapture', onLostCapture);
    el.addEventListener('dragstart', onDragStart);
    el.addEventListener('keydown', onKeyDown);

    return () => {
      el.removeEventListener('pointerdown', onPointerDown);
      el.removeEventListener('pointermove', onPointerMove);
      el.removeEventListener('pointerup', onPointerUp);
      el.removeEventListener('pointercancel', onPointerCancel);
      el.removeEventListener('lostpointercapture', onLostCapture);
      el.removeEventListener('dragstart', onDragStart);
      el.removeEventListener('keydown', onKeyDown);
      if (pointerId !== null && el.hasPointerCapture(pointerId)) {
        el.releasePointerCapture(pointerId);
      }
      el.dataset.dragging = 'false';
      glide.current = null;
    };
  }, [scroller, lenis, multiplier, throwSeconds, keyStep, enabled, reduced]);
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** All seven listeners are removed, any pointer still captured is released, the coast target is cleared so the shared rAF idles, and `data-dragging` is reset. The Lenis instance is refcounted by `useLenis`: the last consumer to unmount destroys it, earlier ones only detach.

**Reduced motion.** Dragging still works and is still 1:1 — pulling content with a finger is direct manipulation, not motion someone asked to be spared. What is removed is everything that keeps moving on its own: no Lenis instance is built, so the page scrolls natively, release stops exactly where the hand stopped, and arrow keys jump rather than travel.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Constructing a second Lenis instance instead of sharing one. Both bind `wheel`, both call `preventDefault`, and the page scrolls at exactly double speed — which looks like a badly tuned multiplier and gets "fixed" by halving the multiplier instead.
- Measuring velocity as the mean of per-event deltas. That is px per event, not px per second: the identical physical flick throws half as far on a 120 Hz display, because each event covers half the distance. Divide by real elapsed time.
- Not expiring old samples. Drag fast, hold still for a second, release — the buffer still holds the fast samples and the page launches from a stationary hand.
- Accumulating `scrollTo(current + delta)` per move. `current` is the animated position, which lags the target, so every event throws away the part the sink has not applied yet and the content drifts behind the hand over a long drag. Track the press point and compute the target absolutely.
- Omitting `setPointerCapture`. Drag past the edge of the surface and `pointermove` stops arriving, so the content freezes mid-gesture and then teleports when the pointer comes back.
- Treating `pointerup` as the only terminator. Capture can be revoked with no `pointerup` — the OS claims the gesture, the element is detached, another finger lands — and the surface is left believing a finger is still down, ignoring every later press. `pointercancel` and `lostpointercapture` both have to end the drag.
- Leaving `touch-action` at its default. The compositor claims the touch gesture, `pointermove` stops, and the drag dies in a `pointercancel` on the first frame while the page scrolls natively underneath.
- Forgetting that a host stylesheet with `scroll-behavior: smooth` fights a direct `scrollTop` write on the box sink, which shows up as a rubbery lag under the finger. Set `scroll-behavior: auto` on the scroll container.

## Notes

- The surface takes `tabIndex={0}` and binds `keydown` on itself rather than on `window`, so arrow keys still belong to a search field elsewhere on the page.
- Presses that land on `a, button, input, select, textarea, label` or anything marked `[data-no-drag]` are left alone, so a link inside the draggable area is still a link.
- `dragstart` is prevented on the surface: native HTML5 drag on an inner image or link would otherwise hand back a ghost image instead of a scroll.

## Definition of done

Press and pull upward: the content follows the pointer and stops the moment the pointer does. Flick and release: it coasts on and decays to a stop. Grab it mid-coast and it stops dead under your hand with no forward jump. Start a drag, run the pointer off the edge of the window and release there — the content stops instead of sticking, and the next press still works. Focus the surface and hold ArrowDown for stepped travel. With reduced motion on, release stops instantly.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
363 lines · 2841 words
04

Source

5 files, exactly as installed.

installs tocomponents/motion/drag-throw-scroll.tsx
'use client';

import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useLenis } from '@/hooks/use-lenis';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { clamp, damp, perFrameToSmoothing } from '@/lib/damp';
import { cn } from '@/lib/utils';

/* ==========================================================================
   Drag-Throw Scroll
   --------------------------------------------------------------------------
   Grab the page and pull it. Let go while still moving and it keeps going,
   decaying, exactly as far as the flick deserved. Wheel, drag and arrow keys
   all write to one scroll position, so the three inputs can never disagree.

   Two things make this hard, and neither is the drag itself.

   The first is the throw. Averaging "pixels since the last event" is the
   version everybody writes, and it is frame-rate dependent: the same physical
   flick produces half the velocity at 120 Hz because each event covers half
   the distance. Velocity here is measured in px per SECOND across a fixed
   100 ms window, so a flick throws the same distance on any display, and a
   drag that stalls before release throws nothing at all.

   The second is the pointer lifecycle. `setPointerCapture` on press keeps
   moves arriving after the pointer leaves the element — without it a fast
   drag dies the instant it crosses the edge and the content sticks
   mid-gesture. But capture can also be taken away without a `pointerup`:
   the OS claims the gesture, the element is detached, a second finger
   arrives. `pointercancel` and `lostpointercapture` are therefore both
   terminators, so there is no path that leaves the surface believing a
   finger is still down.
   ========================================================================== */

/** One position sample. `t` is `event.timeStamp`, i.e. ms on the page clock. */
interface Sample {
  y: number;
  t: number;
}

export interface DragThrowScrollProps {
  children?: ReactNode;
  /**
   * The box to scroll. Omit to drive the page through the shared Lenis
   * instance — which is also what gives the throw its glide.
   */
  scroller?: RefObject<HTMLElement | null>;
  /** Scroll px per pointer px, for mouse and pen. Touch is always 1. */
  multiplier?: number;
  /** Seconds of coast after release. Throw distance is velocity × this. */
  throwSeconds?: number;
  /** Arrow-key step in px, applied when the surface has focus. */
  keyStep?: number;
  enabled?: boolean;
  className?: string;
}

// #region critical
/**
 * Lenis's per-frame lerp for the throw, and the same curve expressed for
 * `damp` so a box scroller decays identically to the page. One number, two
 * sinks — tuning it in one place cannot desync them.
 */
const THROW_LERP = 0.05;
const THROW_SMOOTHING = perFrameToSmoothing(THROW_LERP);

const SAMPLE_COUNT = 5;
const SAMPLE_WINDOW_MS = 100;
const MIN_THROW_SPEED = 120;
const SETTLE_EPSILON = 0.5;
const KEY_STEP_SECONDS = 0.2;

/** Presses on real controls belong to those controls, not to the drag. */
const NO_DRAG = 'a,button,input,select,textarea,label,[data-no-drag]';

function scrollBoxOf(scroller?: RefObject<HTMLElement | null>): Element | null {
  if (scroller) return scroller.current;
  return typeof document === 'undefined' ? null : document.scrollingElement;
}

export function DragThrowScroll({
  children,
  scroller,
  multiplier = 2,
  throwSeconds = 0.65,
  keyStep = 100,
  enabled = true,
  className,
}: DragThrowScrollProps) {
  const surface = useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  /* Lenis owns the document and nothing else. A component pointed at its own
     box must not build one — and under reduced motion nobody wants smoothing
     at all, so the page sink degrades to direct `scrollTop` writes. The hook
     is refcounted: several of these on one page share the single instance
     rather than stacking `wheel` handlers that each call `preventDefault`. */
  const viaLenis = enabled && !reduced && scroller === undefined;
  const lenis = useLenis({ enabled: viaLenis });

  /** Coast target for the box sink. `null` means nothing is in flight. */
  const glide = useRef<number | null>(null);

  useRaf(
    (dt) => {
      const to = glide.current;
      const box = scrollBoxOf(scroller);
      if (to === null || !box) return;
      const next = damp(box.scrollTop, to, THROW_SMOOTHING, dt);
      // Snap and stop. An exponential never arrives, so without a floor this
      // writes a sub-pixel delta every frame forever and the loop never idles.
      if (Math.abs(to - next) < SETTLE_EPSILON) {
        glide.current = null;
        box.scrollTop = to;
        return;
      }
      box.scrollTop = next;
    },
    // Only the box sink needs a loop: Lenis animates its own scrollTo, and
    // reduced motion has nothing to animate.
    enabled && !reduced && scroller !== undefined,
  );

  useEffect(() => {
    const el = surface.current;
    if (!el || !enabled) return;

    let pointerId: number | null = null;
    let startY = 0;
    let startScroll = 0;
    let target = 0;
    let gain = 1;
    const samples: Sample[] = [];

    /* -- the sink: Lenis when it exists, the raw box otherwise -------------- */

    const read = (): number => {
      const l = lenis.current;
      // `scroll`, not `targetScroll`. This is read on press, and grabbing a
      // coasting page has to stop it where the content LOOKS, not where it was
      // headed — otherwise the first pixel of the drag jumps forward by
      // whatever the throw had left to run.
      if (l) return l.scroll;
      return scrollBoxOf(scroller)?.scrollTop ?? 0;
    };

    const limitOf = (): number => {
      const l = lenis.current;
      if (l) return l.limit;
      const box = scrollBoxOf(scroller);
      return box ? Math.max(0, box.scrollHeight - box.clientHeight) : 0;
    };

    /** Direct manipulation: the content is under a finger, so no smoothing. */
    const jump = (y: number) => {
      glide.current = null;
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { immediate: true });
        return;
      }
      const box = scrollBoxOf(scroller);
      if (box) box.scrollTop = y;
    };

    /** Release: an exponential decay toward the thrown target. */
    const coast = (y: number) => {
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { lerp: THROW_LERP });
        return;
      }
      glide.current = y;
    };

    const step = (y: number) => {
      const l = lenis.current;
      if (l) {
        l.scrollTo(y, { duration: KEY_STEP_SECONDS });
        return;
      }
      glide.current = null;
      scrollBoxOf(scroller)?.scrollTo({ top: y, behavior: reduced ? 'auto' : 'smooth' });
    };

    /* -- velocity ---------------------------------------------------------- */

    /**
     * Pointer velocity in px/s, measured from the oldest sample still inside
     * the window to the newest. Time-based rather than per-event, so the
     * number describes the hand rather than the display.
     */
    const velocityAt = (now: number): number => {
      let oldest: Sample | undefined;
      for (const sample of samples) {
        if (now - sample.t <= SAMPLE_WINDOW_MS) {
          oldest = sample;
          break;
        }
      }
      const newest = samples[samples.length - 1];
      if (!oldest || !newest) return 0;
      const span = newest.t - oldest.t;
      if (span <= 0) return 0;
      return ((newest.y - oldest.y) / span) * 1000;
    };

    /* -- pointer lifecycle ------------------------------------------------- */

    const onPointerDown = (e: PointerEvent) => {
      if (pointerId !== null || !e.isPrimary) return;
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      if (e.target instanceof Element && e.target.closest(NO_DRAG)) return;

      pointerId = e.pointerId;
      // A finger is physically on the content: any gain but 1 slides the
      // content out from under it. A mouse is an indirect device with a
      // window-height of travel available, so it gets the multiplier.
      gain = e.pointerType === 'touch' ? 1 : multiplier;
      startY = e.clientY;
      startScroll = read();
      target = startScroll;
      jump(startScroll);
      samples.length = 0;
      samples.push({ y: e.clientY, t: e.timeStamp });
      // Captured on press, not on some later threshold: by the time a
      // threshold would trip, the pointer may already be off the element.
      el.setPointerCapture(e.pointerId);
      el.dataset.dragging = 'true';
    };

    const onPointerMove = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      // Absolute, from the press point. Accumulating per-event deltas onto the
      // live scroll position loses whatever the sink has not applied yet, and
      // the content drifts behind the hand over a long drag.
      target = clamp(startScroll - (e.clientY - startY) * gain, 0, limitOf());
      jump(target);
      samples.push({ y: e.clientY, t: e.timeStamp });
      if (samples.length > SAMPLE_COUNT) samples.shift();
    };

    const release = (e: PointerEvent, thrown: boolean) => {
      if (e.pointerId !== pointerId) return;
      pointerId = null;
      el.dataset.dragging = 'false';
      if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);

      if (!thrown || reduced) {
        samples.length = 0;
        return;
      }
      samples.push({ y: e.clientY, t: e.timeStamp });
      const velocity = velocityAt(e.timeStamp);
      samples.length = 0;
      const scrollVelocity = -velocity * gain;
      if (Math.abs(scrollVelocity) < MIN_THROW_SPEED) return;
      coast(clamp(target + scrollVelocity * throwSeconds, 0, limitOf()));
    };

    const onPointerUp = (e: PointerEvent) => release(e, true);
    // Both of these are aborts, so neither throws. `pointercancel` is the OS
    // taking the gesture; `lostpointercapture` is the catch-all — capture
    // revoked with no `pointerup` at all, which is the one path that would
    // otherwise leave the surface stuck believing a finger is still down. It
    // also fires immediately after a normal release, where the `pointerId`
    // guard above has already made it a no-op.
    const onPointerCancel = (e: PointerEvent) => release(e, false);
    const onLostCapture = (e: PointerEvent) => release(e, false);

    // Native HTML5 drag on any image or link inside would hijack the gesture
    // and hand back a ghost image instead of a scroll.
    const onDragStart = (e: Event) => e.preventDefault();

    const onKeyDown = (e: KeyboardEvent) => {
      const dir = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
      if (dir === 0) return;
      e.preventDefault();
      step(clamp(read() + dir * keyStep, 0, limitOf()));
    };

    el.addEventListener('pointerdown', onPointerDown);
    el.addEventListener('pointermove', onPointerMove);
    el.addEventListener('pointerup', onPointerUp);
    el.addEventListener('pointercancel', onPointerCancel);
    el.addEventListener('lostpointercapture', onLostCapture);
    el.addEventListener('dragstart', onDragStart);
    el.addEventListener('keydown', onKeyDown);

    return () => {
      el.removeEventListener('pointerdown', onPointerDown);
      el.removeEventListener('pointermove', onPointerMove);
      el.removeEventListener('pointerup', onPointerUp);
      el.removeEventListener('pointercancel', onPointerCancel);
      el.removeEventListener('lostpointercapture', onLostCapture);
      el.removeEventListener('dragstart', onDragStart);
      el.removeEventListener('keydown', onKeyDown);
      if (pointerId !== null && el.hasPointerCapture(pointerId)) {
        el.releasePointerCapture(pointerId);
      }
      el.dataset.dragging = 'false';
      glide.current = null;
    };
  }, [scroller, lenis, multiplier, throwSeconds, keyStep, enabled, reduced]);
  // #endregion critical

  return (
    <div
      ref={surface}
      // Rendered rather than added later so the attribute exists in the
      // server markup and the `data-` variants below have something to miss.
      data-dragging="false"
      tabIndex={enabled ? 0 : -1}
      className={cn(
        'relative',
        enabled &&
          // `touch-action: none` is what stops the browser scrolling the page
          // itself while we scroll it: without it a touch drag is claimed by
          // the compositor, `pointermove` stops arriving, and the gesture ends
          // in a `pointercancel` on the first frame.
          'touch-none cursor-grab data-[dragging=true]:cursor-grabbing data-[dragging=true]:select-none',
        className,
      )}
    >
      {children}
    </div>
  );
}
05

API

Every parameter, its type and what happens when you change it.

function DragThrowScroll(props: { children?: ReactNode; scroller?: RefObject<HTMLElement | null>; multiplier?: number; throwSeconds?: number; keyStep?: number; enabled?: boolean; className?: string }): JSX.Element
Parameters
childrenReactNodeThe draggable content. The surface is position: relative and nothing else.
scrollerRefObject<HTMLElement | null>Box to scroll. Omit to drive the document through the shared Lenis instance.
multipliernumber = 2Scroll px per pointer px for mouse and pen. Touch is always 1.
throwSecondsnumber = 0.65Time constant of the release throw; distance is velocity × this.
keyStepnumber = 100Arrow-key step in px while the surface has focus.
enabledboolean = trueFalse removes every listener and restores native scrolling.
classNamestringMerged onto the drag surface.
06

How it works

The technique in order, not the code in order.

  1. 01

    On pointerdown the surface calls setPointerCapture and records startY plus startScroll = lenis.scroll — the animated position, not targetScroll, so grabbing a coasting page stops the content where the eye sees it rather than where the throw was headed.

  2. 02

    Every pointermove recomputes the scroll target absolutely: clamp(startScroll - (clientY - startY) * gain, 0, limit). Gain is multiplier for mouse and pen and hard-wired to 1 for touch, because a finger is physically on the content and any other value slides the content out from under it.

  3. 03

    Positions are pushed into a 5-entry ring of { y, timeStamp }. On release, velocity is (newest.y - oldest.y) / (newest.t - oldest.t) * 1000 px/s using only samples inside a 100 ms window, so the number describes the hand rather than the event rate.

  4. 04

    The throw target is target + (-velocity * gain) * throwSeconds, clamped to the scroll limit. Below MIN_THROW_SPEED nothing is issued at all, so a careful release parks instead of drifting.

  5. 05

    The decay is one curve expressed twice: lenis.scrollTo(y, { lerp: 0.05 }) for the page, and damp(scrollTop, y, perFrameToSmoothing(0.05), dt) on the shared rAF for a box scroller, which stops writing once it is within 0.5 px of target.

  6. 06

    ArrowUp / ArrowDown on the focused surface step by keyStep px through scrollTo(y, { duration: 0.2 }), so keyboard, wheel and hand all mutate the same single position.

Load-bearing constants

Load-bearing constants
multiplier = 2Scroll px per mouse px. At 1 a long page needs a grab per viewport, because the cursor runs out of window; above ~3 the content outruns the hand and the gesture stops reading as direct manipulation. Touch ignores this entirely and uses 1.
throwSeconds = 0.65Coast distance is velocity × this, so it is the time constant of the flick. Under ~0.3 a hard flick barely outruns the release and the throw is invisible; over ~1 a flick crosses the whole document and lands somewhere the user did not aim.
THROW_LERP = 0.05Per-frame fraction of the remaining gap, i.e. a long slow settle — the point of a throw is that it decays visibly. At 0.2 it arrives in a few frames and reads as a jump; perFrameToSmoothing converts the same number for the damp path so the two sinks decay identically.
SAMPLE_WINDOW_MS = 100Only samples newer than this count toward the release velocity. Without the window, a drag that travels fast and then stops dead for a second still throws, because the fast samples are still in the buffer — the page launches from a hand that was holding still.
SAMPLE_COUNT = 5Ring length. One sample is pure jitter, and more than about five spans long enough that the window filter is doing all the work anyway; five covers roughly 80 ms at 60 Hz, which is one flick.
MIN_THROW_SPEED = 120 px/sBelow this no throw is issued. At 0.65 s of coast, 120 px/s is a 78 px slide — around the point where a deliberate stop stops feeling deliberate. Lower and the content creeps after every release.
SETTLE_EPSILON = 0.5 pxAn exponential never actually arrives. Without a floor the box sink writes a sub-pixel scrollTop every frame forever, so the rAF subscription never idles and the scroll event never stops firing.
KEY_STEP_SECONDS = 0.2Arrow-key travel time. Instant is disorienting because nothing connects the two positions; beyond ~0.35 s holding the key queues steps faster than they complete and the page falls behind the keyboard.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

Dragging still works and is still 1:1 — pulling content with a finger is direct manipulation, not motion someone asked to be spared. What is removed is everything that keeps moving on its own: no Lenis instance is built, so the page scrolls natively, release stops exactly where the hand stopped, and arrow keys jump rather than travel.

Cleanup

All seven listeners are removed, any pointer still captured is released, the coast target is cleared so the shared rAF idles, and data-dragging is reset. The Lenis instance is refcounted by useLenis: the last consumer to unmount destroys it, earlier ones only detach.

Definition of done

Press and pull upward: the content follows the pointer and stops the moment the pointer does. Flick and release: it coasts on and decays to a stop. Grab it mid-coast and it stops dead under your hand with no forward jump. Start a drag, run the pointer off the edge of the window and release there — the content stops instead of sticking, and the next press still works. Focus the surface and hold ArrowDown for stepped travel. With reduced motion on, release stops instantly.

Pitfalls

  • Constructing a second Lenis instance instead of sharing one. Both bind wheel, both call preventDefault, and the page scrolls at exactly double speed — which looks like a badly tuned multiplier and gets "fixed" by halving the multiplier instead.
  • Measuring velocity as the mean of per-event deltas. That is px per event, not px per second: the identical physical flick throws half as far on a 120 Hz display, because each event covers half the distance. Divide by real elapsed time.
  • Not expiring old samples. Drag fast, hold still for a second, release — the buffer still holds the fast samples and the page launches from a stationary hand.
  • Accumulating scrollTo(current + delta) per move. current is the animated position, which lags the target, so every event throws away the part the sink has not applied yet and the content drifts behind the hand over a long drag. Track the press point and compute the target absolutely.
  • Omitting setPointerCapture. Drag past the edge of the surface and pointermove stops arriving, so the content freezes mid-gesture and then teleports when the pointer comes back.
  • Treating pointerup as the only terminator. Capture can be revoked with no pointerup — the OS claims the gesture, the element is detached, another finger lands — and the surface is left believing a finger is still down, ignoring every later press. pointercancel and lostpointercapture both have to end the drag.
  • Leaving touch-action at its default. The compositor claims the touch gesture, pointermove stops, and the drag dies in a pointercancel on the first frame while the page scrolls natively underneath.
  • Forgetting that a host stylesheet with scroll-behavior: smooth fights a direct scrollTop write on the box sink, which shows up as a rubbery lag under the finger. Set scroll-behavior: auto on the scroll container.

Notes

  • The surface takes tabIndex={0} and binds keydown on itself rather than on window, so arrow keys still belong to a search field elsewhere on the page.
  • Presses that land on a, button, input, select, textarea, label or anything marked [data-no-drag] are left alone, so a link inside the draggable area is still a link.
  • dragstart is prevented on the surface: native HTML5 drag on an inner image or link would otherwise hand back a ghost image instead of a scroll.