A drag surface laid over a pinned horizontal section. Instead of a tuned drag multiplier it reads the pin's live geometry — the scroll span it occupies and the pixels the strip travels — and converts one into the other, so a pixel of thumb is a pixel of track regardless of panel widths or zoom. Release throws it, and the throw decays in wall-clock time rather than per frame.
Also installs pinned-horizontal-track 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.
# Build: Drag Mapped To Pin ProgressA drag surface laid over a pinned horizontal section. Instead of a tuned drag multiplier it reads the pin's live geometry — the scroll span it occupies and the pixels the strip travels — and converts one into the other, so a pixel of thumb is a pixel of track regardless of panel widths or zoom. Release throws it, and the throw decays in wall-clock time rather than per frame.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 doesDrag a pinned horizontal strip and it moves exactly one pixel per pixel, then throws and settles.- Category: scroll- Runtime cost: dom- Dependencies: gsap@^3.15.0## How it works1. The conversion is `ratio = (trigger.end - trigger.start) / getDistance()`: scroll pixels the pin occupies, divided by horizontal pixels the strip travels. Both are read on every use because ScrollTrigger recomputes `end` on each refresh.2. During a drag the scroll position is set absolutely — `scrollTop = pressScrollTop - dx * ratio` — using the offset from the press point, never an accumulation of per-move deltas.3. Velocity is sampled as `(clientX - lastX) / dt` in seconds and clamped to ±4000 px/s, so a single stuttering event pair cannot record an implausible throw.4. On release the velocity decays with `damp(v, 0, friction, dt)` and the scroll advances by `-v * ratio * dt` each frame, so a 144 Hz display and a 60 Hz display travel the same distance in the same wall-clock time.5. The loop self-parks: below 20 px/s the velocity is zeroed and the callback returns immediately on subsequent frames, so an idle component costs one comparison per frame.6. `trigger.progress` is rounded to three decimals and written to a `--pin-progress` custom property on the surface only when it changes, so a progress rail can be pure CSS with no per-frame React render.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `friction = 0.002` Fraction of throw velocity remaining after one second. 0.002 lands a hard flick in roughly half a second. Near 0.1 the throw stops dead and the release feels like hitting a wall; above ~0.05 it coasts long enough that the user reaches for the scrollbar to stop it.- `THROW_FLOOR = 20` Pixels per second below which the throw is cut and the loop parks. Exponential decay never reaches zero, so without a floor the component writes a sub-pixel scroll change forever and never idles.- `MAX_THROW = 4000` Velocity clamp in px/s. Two pointer events 2 ms apart produce an enormous instantaneous velocity; unclamped, a single jittery sample launches the track across its entire range.- `release timeout = 80 ms` Hold still for longer than this before letting go and the release is not a throw. Without it, pausing mid-drag and releasing fires the stale velocity from the last movement.- `progress quantisation = 3 decimals` Below this the custom property is rewritten on nearly every frame with a change no one can see; above it the progress rail visibly steps.## API```tsfunction DragToPinProgress(props: { children: ReactNode; scroller: RefObject<HTMLElement | null>; trigger: ScrollTrigger | null; getDistance?: () => number; friction?: number; className?: string }): JSX.Element```- `children: ReactNode` The pinned section and anything overlaying it.- `scroller: RefObject<HTMLElement | null>` The scroll container whose `scrollTop` the drag writes.- `trigger: ScrollTrigger | null` The pinned trigger being scrubbed. Dragging is disabled while null.- `getDistance: () => number` Live horizontal travel of the strip in px. Omit for a 1:1 scroll mapping.- `friction: number` — default `0.002` Fraction of throw velocity left after one second.- `className: string` Merged onto the drag surface.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsxexport function DragToPinProgress({ children, scroller, trigger, getDistance, friction = 0.002, className,}: DragToPinProgressProps) { const surface = useRef<HTMLDivElement>(null); const startScroll = useRef(0); const throwVelocity = useRef(0); const lastMoveX = useRef(0); const lastMoveTime = useRef(0); const lastProgress = useRef(-1); const reduced = useReducedMotion(); // Scroll pixels per pixel of horizontal track travel. Read live, because // `end` is recomputed on every ScrollTrigger refresh and the panel widths // change with the container. const ratio = () => { if (!trigger) return 1; const span = trigger.end - trigger.start; const travel = getDistance?.() ?? span; return travel > 0 ? span / travel : 1; }; const applyScroll = (value: number) => { const el = scroller.current; if (!el) return; const max = el.scrollHeight - el.clientHeight; el.scrollTop = clamp(value, 0, max); }; useDrag(surface, { enabled: trigger !== null, onDragStart: (state) => { throwVelocity.current = 0; startScroll.current = scroller.current?.scrollTop ?? 0; lastMoveX.current = state.clientX; lastMoveTime.current = performance.now(); }, onDrag: (state) => { // Absolute, from the press point — not an accumulation of deltas. // Accumulating drifts, because every clamp at the ends silently eats // part of a delta and the track never returns to where the hand is. applyScroll(startScroll.current - state.dx * ratio()); const now = performance.now(); const dt = (now - lastMoveTime.current) / 1000; if (dt > 0) { const instant = (state.clientX - lastMoveX.current) / dt; throwVelocity.current = clamp(instant, -MAX_THROW, MAX_THROW); lastMoveX.current = state.clientX; lastMoveTime.current = now; } }, onDragEnd: (_state, moved) => { // A tap is not a throw. Without this, releasing after a pause launches // the track at whatever velocity the last move happened to record. if (!moved || reduced || performance.now() - lastMoveTime.current > 80) { throwVelocity.current = 0; } }, }); useRaf((dt) => { const el = surface.current; // Publish the pin's own progress as a CSS variable rather than state: // a progress rail must not cost a React render per frame. if (el && trigger) { const p = Math.round(trigger.progress * 1000) / 1000; if (p !== lastProgress.current) { lastProgress.current = p; el.style.setProperty('--pin-progress', String(p)); } } if (throwVelocity.current === 0) return; // Exponential decay in wall-clock time, so the throw travels the same // distance on a 60 Hz panel as on a 144 Hz one. throwVelocity.current = damp(throwVelocity.current, 0, friction, dt); if (Math.abs(throwVelocity.current) < THROW_FLOOR) { throwVelocity.current = 0; return; } const current = scroller.current?.scrollTop ?? 0; applyScroll(current - throwVelocity.current * ratio() * dt); });```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** `useDrag` removes its pointerdown/move/up/cancel and capture-phase click listeners and releases any outstanding pointer capture; `useRaf` unsubscribes from the shared loop. The `--pin-progress` property dies with the element, and no timers or tweens are created that could outlive it.**Reduced motion.** Dragging still works and still maps one pixel to one pixel — it is an input method, not decoration. The inertial throw is suppressed, so the strip stops the instant the pointer is released instead of coasting.**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 beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- Inventing a drag multiplier. It is correct for exactly one panel count and one container width; change either and the strip either outruns the hand or lags behind it, and the number gets re-tuned by eye forever.- Accumulating per-move deltas instead of using the offset from the press point. Every clamp at the ends of the range silently swallows part of a delta, so the track drifts away from the hand and never comes back.- Caching `trigger.end - trigger.start`. ScrollTrigger recomputes it on every refresh — a font swap or a resize — and a cached span makes the drag mapping wrong for the rest of the session.- Decaying the throw with a per-frame constant (`v *= 0.95`). The throw travels less than half as far on a 120 Hz display, so the component feels different on different hardware.- Setting `touch-action: none` on the drag surface. It kills native vertical scrolling over the whole pinned section, so on a phone the page appears frozen; `pan-y` keeps vertical scroll and hands over only the horizontal gesture.- Not swallowing the click after a drag. Releasing over a panel fires that panel's click handler, so throwing the track opens whatever happened to be under the finger.## Notes- Designed to sit around `pinned-horizontal-track`, whose `onTrack` payload supplies both `trigger` and `getDistance`.- Because the drag writes `scrollTop`, ScrollTrigger updates through its normal scroll listener — no manual `ScrollTrigger.update()` call, and no second source of truth for position.## Definition of donePress and drag left: the strip follows the pointer exactly, and a panel edge under your thumb stays under your thumb. Flick and release: it coasts and settles smoothly. Drag to either end and it stops flush with no rubber band. The progress rail matches the strip at all times, including while throwing. With reduced motion on, release stops it dead.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import gsap from 'gsap';import { ScrollTrigger } from 'gsap/ScrollTrigger';import { useRef, type ReactNode, type RefObject } from 'react';import { useDrag } from '@/hooks/use-drag';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { clamp, damp } from '@/lib/damp';import { cn } from '@/lib/utils';/* ========================================================================== Drag Mapped To Pin Progress -------------------------------------------------------------------------- A pinned horizontal strip is a lie told with vertical scroll, and the lie breaks the moment someone tries to drag it: the strip moves sideways, the scrollbar moves down, and any hand-tuned "drag multiplier" is wrong the first time the panel widths change. The fix is to stop guessing. The pin knows how many scroll pixels it occupies (`trigger.end - trigger.start`) and the strip knows how many pixels it travels. Their ratio converts drag pixels into scroll pixels exactly, so one pixel of thumb movement is one pixel of track movement at any layout, any zoom, any panel count. ========================================================================== */if (typeof window !== 'undefined') { gsap.registerPlugin(ScrollTrigger);}export interface DragToPinProgressProps { children: ReactNode; /** The scroll container the pin lives in. Its `scrollTop` is what moves. */ scroller: RefObject<HTMLElement | null>; /** The pinned trigger being scrubbed. `null` until it is built. */ trigger: ScrollTrigger | null; /** Live horizontal travel of the strip in px. Defaults to a 1:1 mapping. */ getDistance?: () => number; /** Fraction of throw velocity still left after one second. */ friction?: number; className?: string;}const THROW_FLOOR = 20;const MAX_THROW = 4000;// #region criticalexport function DragToPinProgress({ children, scroller, trigger, getDistance, friction = 0.002, className,}: DragToPinProgressProps) { const surface = useRef<HTMLDivElement>(null); const startScroll = useRef(0); const throwVelocity = useRef(0); const lastMoveX = useRef(0); const lastMoveTime = useRef(0); const lastProgress = useRef(-1); const reduced = useReducedMotion(); // Scroll pixels per pixel of horizontal track travel. Read live, because // `end` is recomputed on every ScrollTrigger refresh and the panel widths // change with the container. const ratio = () => { if (!trigger) return 1; const span = trigger.end - trigger.start; const travel = getDistance?.() ?? span; return travel > 0 ? span / travel : 1; }; const applyScroll = (value: number) => { const el = scroller.current; if (!el) return; const max = el.scrollHeight - el.clientHeight; el.scrollTop = clamp(value, 0, max); }; useDrag(surface, { enabled: trigger !== null, onDragStart: (state) => { throwVelocity.current = 0; startScroll.current = scroller.current?.scrollTop ?? 0; lastMoveX.current = state.clientX; lastMoveTime.current = performance.now(); }, onDrag: (state) => { // Absolute, from the press point — not an accumulation of deltas. // Accumulating drifts, because every clamp at the ends silently eats // part of a delta and the track never returns to where the hand is. applyScroll(startScroll.current - state.dx * ratio()); const now = performance.now(); const dt = (now - lastMoveTime.current) / 1000; if (dt > 0) { const instant = (state.clientX - lastMoveX.current) / dt; throwVelocity.current = clamp(instant, -MAX_THROW, MAX_THROW); lastMoveX.current = state.clientX; lastMoveTime.current = now; } }, onDragEnd: (_state, moved) => { // A tap is not a throw. Without this, releasing after a pause launches // the track at whatever velocity the last move happened to record. if (!moved || reduced || performance.now() - lastMoveTime.current > 80) { throwVelocity.current = 0; } }, }); useRaf((dt) => { const el = surface.current; // Publish the pin's own progress as a CSS variable rather than state: // a progress rail must not cost a React render per frame. if (el && trigger) { const p = Math.round(trigger.progress * 1000) / 1000; if (p !== lastProgress.current) { lastProgress.current = p; el.style.setProperty('--pin-progress', String(p)); } } if (throwVelocity.current === 0) return; // Exponential decay in wall-clock time, so the throw travels the same // distance on a 60 Hz panel as on a 144 Hz one. throwVelocity.current = damp(throwVelocity.current, 0, friction, dt); if (Math.abs(throwVelocity.current) < THROW_FLOOR) { throwVelocity.current = 0; return; } const current = scroller.current?.scrollTop ?? 0; applyScroll(current - throwVelocity.current * ratio() * dt); }); // #endregion critical return ( <div ref={surface} className={cn( 'relative select-none', trigger ? 'cursor-grab active:cursor-grabbing' : null, className, )} // pan-y, not none: the browser keeps vertical scrolling on touch and // only hands us the horizontal gesture, so the page never feels stuck. style={{ touchAction: 'pan-y' }} > {children} </div> );}
/* ========================================================================== Frame-rate-independent interpolation. -------------------------------------------------------------------------- The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice as fast on a 120 Hz display as on a 60 Hz one, so the same code feels different on different hardware and visibly speeds up when the browser drops frames. `damp` fixes it by raising the retention factor to the power of elapsed time, which is the exact continuous form of the same curve. ========================================================================== *//** * Fraction of the remaining distance to close this frame. * * @param smoothing Fraction of the gap that REMAINS after one second. * Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft, * 0.5 ≈ slow drift. * @param dt Seconds elapsed since the previous frame. */export function dampFactor(smoothing: number, dt: number): number { return 1 - Math.pow(smoothing, dt);}/** Move `current` toward `target` at a rate independent of frame rate. */export function damp( current: number, target: number, smoothing: number, dt: number,): number { return current + (target - current) * dampFactor(smoothing, dt);}/** * The per-frame lerp constant an author measured at 60fps, converted into the * `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame" * be ported without re-tuning by eye: * * damp(x, t, perFrameToSmoothing(0.1), dt) */export function perFrameToSmoothing(alpha: number, baseFps = 60): number { return Math.pow(1 - alpha, baseFps);}export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t;}export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v;}export function inverseLerp(a: number, b: number, v: number): number { return a === b ? 0 : (v - a) / (b - a);}export function mapRange( v: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp = true,): number { const t = inverseLerp(inMin, inMax, v); const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t); return r;}/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */export function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp(inverseLerp(edge0, edge1, x), 0, 1); return t * t * (3 - 2 * t);}/** * Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at * distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and * no branches, which is why carousels that use it feel analogue rather than * stepped. */export function gaussian(distance: number, falloff = 1): number { const d = distance * falloff; return Math.exp(-(d * d));}
'use client';import { useEffect, useRef, type RefObject } from 'react';/* ========================================================================== Pointer drag with capture, a dead zone, and a click gate. -------------------------------------------------------------------------- Three things every hand-rolled drag gets wrong: 1. No pointer capture. Drag fast, leave the element, and `pointermove` stops firing — the track sticks mid-throw. `setPointerCapture` keeps events coming to the origin element until release. 2. No dead zone. Without one, a 1 px wobble during a click is a drag, and the drag handler fights the click handler on every tap. 3. The click still fires. After a real drag, the browser dispatches `click` on whatever is under the pointer, so throwing a carousel opens whichever slide you happen to release over. Swallowing that one click in the capture phase is the fix; `onDragEnd`'s `moved` flag tells the caller whether it happened. ========================================================================== */export interface DragState { /** Pixels from the press point. */ dx: number; dy: number; /** Pixels since the previous move event. */ deltaX: number; deltaY: number; /** Viewport pixels. */ clientX: number; clientY: number;}export interface UseDragOptions { /** Pixels of travel before it counts as a drag. Default 4. */ threshold?: number; /** Ignore the wrong mouse buttons / a secondary touch. Default true. */ primaryOnly?: boolean; onDragStart?: (state: DragState) => void; onDrag?: (state: DragState) => void; /** `moved` is false for a tap: the caller should treat it as a click. */ onDragEnd?: (state: DragState, moved: boolean) => void; enabled?: boolean;}export function useDrag( ref: RefObject<HTMLElement | null>, options: UseDragOptions = {},): { current: boolean } { const { threshold = 4, primaryOnly = true, enabled = true } = options; const dragging = useRef(false); const handlers = useRef(options); handlers.current = options; useEffect(() => { const el = ref.current; if (!el || !enabled) return; let pointerId: number | null = null; let startX = 0; let startY = 0; let lastX = 0; let lastY = 0; let passed = false; let swallowClick = false; const stateAt = (clientX: number, clientY: number): DragState => ({ dx: clientX - startX, dy: clientY - startY, deltaX: clientX - lastX, deltaY: clientY - lastY, clientX, clientY, }); const onPointerDown = (e: PointerEvent) => { if (primaryOnly && !e.isPrimary) return; if (e.pointerType === 'mouse' && e.button !== 0) return; pointerId = e.pointerId; startX = lastX = e.clientX; startY = lastY = e.clientY; passed = false; // Capture on press, not on threshold: by the time the threshold is // crossed the pointer may already be outside the element. el.setPointerCapture(e.pointerId); }; const onPointerMove = (e: PointerEvent) => { if (pointerId !== e.pointerId) return; const state = stateAt(e.clientX, e.clientY); if (!passed) { if (Math.hypot(state.dx, state.dy) < threshold) return; passed = true; dragging.current = true; handlers.current.onDragStart?.(state); } handlers.current.onDrag?.(state); lastX = e.clientX; lastY = e.clientY; }; const finish = (e: PointerEvent) => { if (pointerId !== e.pointerId) return; const state = stateAt(e.clientX, e.clientY); const moved = passed; if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); pointerId = null; passed = false; dragging.current = false; swallowClick = moved; handlers.current.onDragEnd?.(state, moved); }; // Capture phase, so it runs before any child's own click handler. const onClick = (e: MouseEvent) => { if (!swallowClick) return; swallowClick = false; e.preventDefault(); e.stopPropagation(); }; el.addEventListener('pointerdown', onPointerDown); el.addEventListener('pointermove', onPointerMove); el.addEventListener('pointerup', finish); el.addEventListener('pointercancel', finish); el.addEventListener('click', onClick, true); return () => { el.removeEventListener('pointerdown', onPointerDown); el.removeEventListener('pointermove', onPointerMove); el.removeEventListener('pointerup', finish); el.removeEventListener('pointercancel', finish); el.removeEventListener('click', onClick, true); if (pointerId !== null && el.hasPointerCapture(pointerId)) { el.releasePointerCapture(pointerId); } dragging.current = false; }; }, [ref, threshold, primaryOnly, enabled]); return dragging;}
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
05
API
Every parameter, its type and what happens when you change it.
The scroll container whose scrollTop the drag writes.
trigger
ScrollTrigger | null
—
The pinned trigger being scrubbed. Dragging is disabled while null.
getDistance
() => number
—
Live horizontal travel of the strip in px. Omit for a 1:1 scroll mapping.
friction
number
= 0.002
Fraction of throw velocity left after one second.
className
string
—
Merged onto the drag surface.
06
How it works
The technique in order, not the code in order.
01
The conversion is ratio = (trigger.end - trigger.start) / getDistance(): scroll pixels the pin occupies, divided by horizontal pixels the strip travels. Both are read on every use because ScrollTrigger recomputes end on each refresh.
02
During a drag the scroll position is set absolutely — scrollTop = pressScrollTop - dx * ratio — using the offset from the press point, never an accumulation of per-move deltas.
03
Velocity is sampled as (clientX - lastX) / dt in seconds and clamped to ±4000 px/s, so a single stuttering event pair cannot record an implausible throw.
04
On release the velocity decays with damp(v, 0, friction, dt) and the scroll advances by -v * ratio * dt each frame, so a 144 Hz display and a 60 Hz display travel the same distance in the same wall-clock time.
05
The loop self-parks: below 20 px/s the velocity is zeroed and the callback returns immediately on subsequent frames, so an idle component costs one comparison per frame.
06
trigger.progress is rounded to three decimals and written to a --pin-progress custom property on the surface only when it changes, so a progress rail can be pure CSS with no per-frame React render.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
friction
= 0.002
Fraction of throw velocity remaining after one second. 0.002 lands a hard flick in roughly half a second. Near 0.1 the throw stops dead and the release feels like hitting a wall; above ~0.05 it coasts long enough that the user reaches for the scrollbar to stop it.
THROW_FLOOR
= 20
Pixels per second below which the throw is cut and the loop parks. Exponential decay never reaches zero, so without a floor the component writes a sub-pixel scroll change forever and never idles.
MAX_THROW
= 4000
Velocity clamp in px/s. Two pointer events 2 ms apart produce an enormous instantaneous velocity; unclamped, a single jittery sample launches the track across its entire range.
release timeout
= 80 ms
Hold still for longer than this before letting go and the release is not a throw. Without it, pausing mid-drag and releasing fires the stale velocity from the last movement.
progress quantisation
= 3 decimals
Below this the custom property is rewritten on nearly every frame with a change no one can see; above it the progress rail visibly steps.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
Dragging still works and still maps one pixel to one pixel — it is an input method, not decoration. The inertial throw is suppressed, so the strip stops the instant the pointer is released instead of coasting.
Cleanup
useDrag removes its pointerdown/move/up/cancel and capture-phase click listeners and releases any outstanding pointer capture; useRaf unsubscribes from the shared loop. The --pin-progress property dies with the element, and no timers or tweens are created that could outlive it.
Definition of done
Press and drag left: the strip follows the pointer exactly, and a panel edge under your thumb stays under your thumb. Flick and release: it coasts and settles smoothly. Drag to either end and it stops flush with no rubber band. The progress rail matches the strip at all times, including while throwing. With reduced motion on, release stops it dead.
Pitfalls
Inventing a drag multiplier. It is correct for exactly one panel count and one container width; change either and the strip either outruns the hand or lags behind it, and the number gets re-tuned by eye forever.
Accumulating per-move deltas instead of using the offset from the press point. Every clamp at the ends of the range silently swallows part of a delta, so the track drifts away from the hand and never comes back.
Caching trigger.end - trigger.start. ScrollTrigger recomputes it on every refresh — a font swap or a resize — and a cached span makes the drag mapping wrong for the rest of the session.
Decaying the throw with a per-frame constant (v *= 0.95). The throw travels less than half as far on a 120 Hz display, so the component feels different on different hardware.
Setting touch-action: none on the drag surface. It kills native vertical scrolling over the whole pinned section, so on a phone the page appears frozen; pan-y keeps vertical scroll and hands over only the horizontal gesture.
Not swallowing the click after a drag. Releasing over a panel fires that panel's click handler, so throwing the track opens whatever happened to be under the finger.
Notes
Designed to sit around pinned-horizontal-track, whose onTrack payload supplies both trigger and getDistance.
Because the drag writes scrollTop, ScrollTrigger updates through its normal scroll listener — no manual ScrollTrigger.update() call, and no second source of truth for position.