Skip to content
Kinetic
LayoutDOM8 files

Drag Marquee

One unwrapped accumulator carries the idle drift, the drag and the throw; the wrap happens once, in the follow tween's `modifiers`, as `mod(x, period) - period`. Because that is a true modulo it is total — a delta of four periods in one frame lands correctly — and because it is the identity for `x` already inside `[-period, 0)` it never shows a seam. Pressing scatters the cards on a single paused timeline that is played forwards and reversed, and releasing hands the row over to a plugin-free inertial throw.

marqueedraginfinitewrappointer-eventsgsap

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-marquee.json
pulls ingsap@^3.15.0

Also installs use-gsap, use-in-view, use-reduced-motion, use-resize-observer, damp, prng, wrap 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 Marquee

One unwrapped accumulator carries the idle drift, the drag and the throw; the wrap happens once, in the follow tween's `modifiers`, as `mod(x, period) - period`. Because that is a true modulo it is total — a delta of four periods in one frame lands correctly — and because it is the identity for `x` already inside `[-period, 0)` it never shows a seam. Pressing scatters the cards on a single paused timeline that is played forwards and reversed, and releasing hands the row over to a plugin-free inertial throw.

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

An endless row that drifts, takes a drag and a throw, and scatters its cards while your hand is down.

- Category: layout
- Runtime cost: dom
- Dependencies: gsap@^3.15.0

## How it works

1. The row is `N` copies of the children inside a `w-max` flex track. `period` is the measured width of one copy — `getBoundingClientRect().width`, not `offsetWidth`, because the latter rounds and half a pixel of error is half a pixel of gap that only ever shows at the seam. The copy count is `clamp(ceil(hostWidth / period) + 1, 2, 8)`, which is exactly enough that a translation bounded by one period can never expose the end of the track.
2. Everything that moves the row adds to one number that is never corrected: the drift subtracts `speed * dt`, each `pointermove` adds `dx`, the release adds the throw distance. It walks off to -50000 and nothing cares, because the only consumer of it applies a modulo.
3. That modulo lives in `gsap.quickTo`'s `modifiers`, which post-processes the value on its way to the target. It cannot feed back: `resetTo` advances the tween's start from its own `s + ratio * c` rather than re-reading the target, so the wrapped value is output-only. Wrapping in `onUpdate` instead makes the next retarget interpolate from a value the tween does not believe in, and the row lurches a whole period.
4. For `x` in `[-period, 0)`, `mod(x, period) === x + period`, so `mod(x, period) - period === x`. The wrap is therefore the identity across the entire range the row normally occupies and only bites when the accumulator leaves it — the two sides of the boundary paint identical pixels, so there is nothing to snap.
5. The gap is a trailing `margin-right` on each tile *inside* a copy rather than `gap` on the track. A track-level gap sits between copies too, so the measured copy width would no longer equal the wrap period and the seam would gain one gap of drift every lap.
6. Release velocity is measured over a rolling ~70 ms window of `(accumulator, time)`, then projected with the `power3.out` identity: `p(t) = 1 - (1 - t)^3` has `p'(0) = 3`, so a tween of duration `D` covering `delta` departs at `3 * delta / D`. Retargeting by `v * D / 3` continues the gesture at exactly the speed the hand left, with the core library and no `InertiaPlugin`.
7. The press scatter is one paused timeline built once and stored in a ref, `play()` on `pointerdown` and `reverse()` on the release path. Per-tile rotation and offset come from a seeded generator indexed by position *within* a copy, so every copy of a tile scatters identically and the duplication stays invisible while the cards move.

## 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".

- `copies = clamp(ceil(hostWidth / period) + 1, 2, 8)`
  The `+ 1` is the copy that covers the wrap: with the translation bounded to one period, the window can start one full period into the track and still needs `hostWidth` of content after it. Drop it and the tail of the row shows background for part of every lap. The floor of 2 is the minimum that can hide a seam at all; the ceiling of 8 stops a one-word tile in a wide hero from producing hundreds of nodes.
- `POWER3_OUT_SLOPE = 3`
  The initial derivative of `power3.out`, and the divisor in `delta = v * D / 3`. Exact, not tuned: change the follow tween's ease and this changes with it — `power2` needs 2, `power4` needs 4. Leave it at 3 against a different ease and every throw is off by a constant factor.
- `smoothing = 0.5`
  Seconds the row takes to catch up with the pointer, and the `D` in the throw projection. Under ~0.2 s the row is welded to the finger and the throw is over before it registers; over ~0.8 s the lag between hand and content is large enough to read as dropped frames rather than as weight.
- `speed = 60`
  Idle drift in px/s. Below ~20 the row looks frozen at a glance and the affordance is lost; above ~120 the text is unreadable at rest, which defeats the point of putting words in it. At 0 the ticker is never registered at all, so a parked marquee costs nothing per frame.
- `VELOCITY_WINDOW = 0.07`
  Seconds of gesture history the release speed is averaged over. Shorter is effectively the last delta, which is noisy at 120 Hz and gives visibly different throws for identical gestures; much longer averages away a deliberate slow-down before release, so the row flies off when the user meant to place it.
- `MAX_VELOCITY = 4000`
  px/s the measured speed is clamped to. A coalesced pointer batch, a tab switch mid-drag or a synthetic event can report tens of thousands; without the clamp one bad sample throws the row several periods, and the wrap makes the result look random rather than fast.
- `MAX_THROW = 1200`
  Pixels one throw may carry. Roughly a screen and a half — far enough that a hard flick feels unbounded, close enough that the content the user was looking at is still findable when it stops.
- `DRAG_THRESHOLD = 5`
  Pixels of travel before a press becomes a drag. Below ~3 the tremor in a normal tap counts as a drag and every tap on a card is swallowed; above ~10 a small deliberate nudge does nothing at all.
- `scatter / spin = 6 (percent) / 10 (deg)`
  Half-ranges for the per-tile offset and rotation on press. The offset is a percentage of each tile's own size, so mixed tile widths scatter proportionally instead of the small ones flying off. Past ~15% and ~25° the tiles overlap their neighbours and the row stops reading as a row; below ~3% and ~5° the press produces no visible acknowledgement.
- `ease: back.inOut(3) = 3`
  The overshoot of the scatter, applied at both ends because the same timeline is played and reversed. It is what makes the cards spring apart and spring back rather than easing politely; at 1 the overshoot is invisible, past ~5 the return visibly overshoots the resting layout and the row wobbles after release.

## API

```ts
function DragMarquee(props: { children: ReactNode; speed?: number; gap?: number; smoothing?: number; scatter?: number; spin?: number; pressScale?: number; scatterDuration?: number; seed?: number | string; className?: string; tileClassName?: string; 'aria-label'?: string }): JSX.Element
```

- `children: ReactNode`
  One tile per child. The whole set is duplicated as many times as the width needs.
- `speed: number` — default `60`
  Idle drift in px/s. Positive drifts left; 0 parks the row and skips the ticker entirely.
- `gap: number` — default `24`
  Pixels between tiles, applied inside a copy so the spacing also spans the seam.
- `smoothing: number` — default `0.5`
  Seconds the row takes to catch up with the pointer. Doubles as the throw duration.
- `scatter: number` — default `6`
  Scatter distance on press, as a percentage of each tile's own size.
- `spin: number` — default `10`
  Half-range of the per-tile rotation on press, in degrees.
- `pressScale: number` — default `0.95`
  Tile scale while the pointer is down.
- `scatterDuration: number` — default `0.5`
  Seconds for the scatter out, and the same again back.
- `seed: number | string` — default `'drag-marquee'`
  Seeds the scatter. The same seed gives the same arrangement on server and client.
- `className: string`
  Merged onto the drag surface.
- `tileClassName: string`
  Merged onto every tile. Size the items here.
- `aria-label: string` — default `'Marquee'`
  Label for the focusable row, which also takes left and right arrow keys.

## 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
/** The only place the track's transform is written. */
const paint = useCallback(() => {
  const track = trackRef.current;
  if (!track) return;
  track.style.transform = `translate3d(${proxyRef.current.x.toFixed(2)}px, 0px, 0px)`;
}, []);

/**
 * Move the accumulator to `value`. With motion allowed this retargets the
 * follow tween — `quickTo` reuses one tween and one `PropTween` rather than
 * allocating a tween per frame — and the wrap happens in its modifier. With
 * reduced motion there is no tween, so the wrap is applied here instead.
 */
const push = useCallback(
  (value: number) => {
    rawRef.current = value;
    const xTo = xToRef.current;
    if (xTo) {
      xTo(value);
      return;
    }
    const period = periodRef.current;
    proxyRef.current.x = period < 1 ? 0 : mod(value, period) - period;
    paint();
  },
  [paint],
);

/**
 * One follow tween for the life of the component. The wrap lives in
 * `modifiers`, which post-processes the value on its way to the target and
 * never feeds back: `resetTo` advances the tween's start from its own
 * `s + ratio * c`, not by re-reading `proxy.x`. Put the wrap anywhere else
 * — in `onUpdate`, say — and the next retarget interpolates from a value the
 * tween does not believe in, and the row lurches a full period.
 */
useEffect(() => {
  const proxy = proxyRef.current;
  if (reduced) {
    xToRef.current = null;
    return;
  }

  const xTo = gsap.quickTo(proxy, 'x', {
    duration: smoothing,
    ease: 'power3',
    modifiers: {
      // Reads the ref, so a resize changes the period without rebuilding
      // the tween — rebuilding mid-throw would restart the ease from rest.
      x: (value: number) => {
        const period = periodRef.current;
        return period < 1 ? 0 : mod(value, period) - period;
      },
    },
    onUpdate: paint,
  });
  xToRef.current = xTo;

  return () => {
    xToRef.current = null;
    gsap.killTweensOf(proxy);
  };
}, [reduced, smoothing, paint]);

useEffect(() => {
  const host = hostRef.current;
  if (!host) return;

  let pointerId: number | null = null;
  let lastX = 0;
  let pressX = 0;
  let pressY = 0;
  let moved = false;
  let swallowClick = false;
  /** Rolling window: the accumulator and clock at the start of the window. */
  let sampleRaw = 0;
  let sampleTime = 0;

  const now = () => performance.now() / 1000;

  const onPointerDown = (e: PointerEvent) => {
    // One pointer owns the row. A second finger arriving mid-throw would
    // otherwise reset the origin and the row would jump.
    if (pointerId !== null || !e.isPrimary) return;
    if (e.pointerType === 'mouse' && e.button !== 0) return;

    pointerId = e.pointerId;
    lastX = e.clientX;
    pressX = e.clientX;
    pressY = e.clientY;
    moved = false;
    sampleRaw = rawRef.current;
    sampleTime = now();
    pressedRef.current = true;

    // Capture on press, not once the threshold is crossed: by then the
    // pointer may already have left the element and `pointermove` would have
    // stopped arriving.
    host.setPointerCapture(e.pointerId);
    // Scatter on press, like a physical stack of cards being pinned — the
    // press, not the drag, is what the eye reads as "I have hold of this".
    scatterRef.current?.play();
  };

  const onPointerMove = (e: PointerEvent) => {
    if (pointerId !== e.pointerId) return;

    const dx = e.clientX - lastX;
    lastX = e.clientX;

    if (!moved) {
      if (Math.hypot(e.clientX - pressX, e.clientY - pressY) < DRAG_THRESHOLD) return;
      moved = true;
    }

    push(rawRef.current + dx);

    // Retire the velocity window once it is full. Measuring over a window
    // rather than off the last delta is what stops "drag, pause, lift" from
    // throwing the row: with no movement there are no events, the window
    // never advances, and the measured speed decays toward zero on its own.
    const t = now();
    if (t - sampleTime > VELOCITY_WINDOW) {
      sampleRaw = rawRef.current;
      sampleTime = t;
    }
  };

  /**
   * Idempotent. Called from `pointerup`, `pointercancel` and
   * `lostpointercapture`, all three of which can be the last event a gesture
   * produces, and at least one of which always arrives — including when the
   * pointer is released outside the window or the OS takes it away for a
   * gesture of its own. Guarding on `pointerId` makes the extra calls no-ops
   * instead of a double throw, and it is what stops a drag that ended
   * off-screen from leaving the row stuck mid-throw with the cards still
   * scattered.
   */
  const finish = (e: PointerEvent) => {
    if (pointerId !== e.pointerId) return;
    const id = pointerId;
    pointerId = null;
    pressedRef.current = false;

    const elapsed = now() - sampleTime;
    const velocity =
      moved && elapsed > 0
        ? clamp((rawRef.current - sampleRaw) / elapsed, -MAX_VELOCITY, MAX_VELOCITY)
        : 0;

    swallowClick = moved;
    moved = false;

    if (host.hasPointerCapture(id)) host.releasePointerCapture(id);
    scatterRef.current?.reverse();

    if (velocity !== 0) {
      // v·D/3 is the distance a `power3.out` of duration D covers when it
      // departs at v. Retargeting by exactly that hands the row over from
      // the finger without a change of speed.
      push(
        rawRef.current +
          clamp((velocity * smoothing) / POWER3_OUT_SLOPE, -MAX_THROW, MAX_THROW),
      );
    }
  };

  // Capture phase, ahead of any child's own handler. A drag ends with a
  // `click` on whatever happens to be under the pointer, so without this the
  // gesture that scrolls the row also activates a card.
  const onClick = (e: MouseEvent) => {
    if (!swallowClick) return;
    swallowClick = false;
    e.preventDefault();
    e.stopPropagation();
  };

  const onKeyDown = (e: KeyboardEvent) => {
    const direction = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
    if (direction === 0) return;
    e.preventDefault();
    const step = perCopy > 0 ? periodRef.current / perCopy : periodRef.current;
    push(rawRef.current + direction * step);
  };

  host.addEventListener('pointerdown', onPointerDown);
  host.addEventListener('pointermove', onPointerMove);
  host.addEventListener('pointerup', finish);
  host.addEventListener('pointercancel', finish);
  host.addEventListener('lostpointercapture', finish);
  host.addEventListener('click', onClick, true);
  host.addEventListener('keydown', onKeyDown);

  return () => {
    host.removeEventListener('pointerdown', onPointerDown);
    host.removeEventListener('pointermove', onPointerMove);
    host.removeEventListener('pointerup', finish);
    host.removeEventListener('pointercancel', finish);
    host.removeEventListener('lostpointercapture', finish);
    host.removeEventListener('click', onClick, true);
    host.removeEventListener('keydown', onKeyDown);
    if (pointerId !== null && host.hasPointerCapture(pointerId)) {
      host.releasePointerCapture(pointerId);
    }
    pointerId = null;
    pressedRef.current = false;
  };
}, [push, smoothing, perCopy]);
```

## Requirements

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

**Cleanup.** Removes `pointerdown`, `pointermove`, `pointerup`, `pointercancel`, `lostpointercapture`, the capture-phase `click` and `keydown`; releases pointer capture if a gesture is still live at unmount; removes the drift callback from `gsap.ticker`; kills the tweens on the proxy object so an in-flight throw cannot call `onUpdate` against a detached track; and reverts the `gsap.context` holding the scatter timeline, which also restores the tiles' inline transforms. Both shared resize observers and the shared intersection observer unregister.

**Reduced motion.** Dragging is unchanged — it is the user's own motion, and tracking it is the component. Everything the component would have animated by itself is dropped: the idle drift is never registered on the ticker, the follow tween is never created (so the row is written straight to the wrapped position, one-to-one with the pointer, and a release does not throw), and the scatter timeline is not built, so pressing moves nothing but the row. Arrow keys still step one tile, instantly.

**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

- Correcting the offset with `if (x < -period) x += period` instead of a modulo. It only ever undoes one period, so a fast flick, a coalesced pointer batch, or a tab that was backgrounded for a second leaves the row several periods out and it jumps to the wrong tile. `mod` is total: any magnitude, one step, right answer.
- Applying the wrap in `onUpdate` rather than in `modifiers`. The tween then interpolates from a start value it computed internally toward a target the wrap has already moved, so the next retarget travels a whole extra period. `modifiers` is post-processing on the way out and never feeds back into the tween's own bookkeeping.
- Putting the gap on the track with `gap` or `space-x`. It lands between copies as well as between tiles, so the measured copy width no longer equals the wrap period; the seam gains one gap per lap and slowly slides out of register. The gap belongs inside the copy, as a trailing margin on every tile.
- Measuring the period with `offsetWidth`. It rounds to whole pixels, and a sub-pixel error in the period is a permanent sub-pixel misalignment at the seam that shows as a hairline flicker once per lap.
- Hard-coding two copies. Two is enough only while one copy is at least as wide as the container; a short tile set in a wide hero runs out of content mid-lap and the row ends in empty space. The count has to come from the measured width.
- Randomising the scatter per DOM node. The duplicates are the whole trick, and the instant the cards move with different rotations the eye reads them as different content — the seam becomes obvious. Index the seed by position within a copy, so copy 3 tile 5 matches copy 1 tile 5.
- Rebuilding the scatter timeline per press, or putting the pressed flag in the `useGsap` deps. `revert()` strips the inline transforms GSAP wrote, so the reverse starts from the untouched stylesheet values and animates nowhere — the cards stay scattered. One paused timeline, `play()` and `reverse()` from the pointer handlers.
- Leaving the idle drift running during a drag. The row creeps out from under the finger, which is precisely what one-to-one tracking exists to prevent; over a slow careful drag the divergence is several tiles.
- Calling `setPointerCapture` only after the drag threshold is crossed. A fast gesture has already left the element by then, `pointermove` stops arriving, and the row sticks at the offset it had when the pointer crossed the boundary.
- Handling only `pointerup`. Release outside the window, or let the OS claim the gesture, and no `pointerup` arrives: the row stays captured, the drift never resumes, and the cards stay scattered mid-throw. `pointercancel` and `lostpointercapture` route to the same idempotent finish, guarded on the stored `pointerId` so the duplicate is a no-op rather than a second throw.
- Letting the click through after a drag. The gesture ends with a `click` on whichever card is under the pointer, so dragging the row also opens a project. It is swallowed in the capture phase, once, and only when the gesture actually moved.

## Notes

- The follow tween is retargeted, not recreated: `gsap.quickTo` keeps one tween and one `PropTween` alive and calls `resetTo` on it, so a marquee dragged for ten seconds allocates nothing per frame.
- The drift is registered on `gsap.ticker` rather than a second rAF loop, so the target and the tween that chases it advance in the same tick. On separate loops the row is one frame stale every other frame, which reads as a faint shimmer at speed.
- Because the drift keeps retargeting the same tween after release, the throw decays exponentially with a time constant of `smoothing / 3` rather than tracing a literal `power3` curve. The departure speed is still exactly the hand's, which is the part the eye judges.
- Only the first copy is exposed to assistive technology; the rest carry `aria-hidden`, so a screen reader reads the list once rather than four times.

## Definition of done

The row drifts on its own. Grab it and it follows your hand exactly, with no creep in the drift direction, and the cards tilt and shrink apart the instant you press — then settle back when you let go. Flick and release: it coasts on at the speed you left, slows, and rejoins the drift with no visible seam or jump. Drag several screens' worth in one fast swipe and the tiles stay in order — nothing teleports. Watch one tile go off the left edge and it reappears on the right at the same spacing as every other gap. Release the pointer outside the browser window and the row keeps drifting instead of freezing scattered. Release over a card and no click reaches it; tap without moving and it does. Resize the container narrower and wider — the seam never appears. Scroll it out of view and the drift stops until it is back.

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

Source

8 files, exactly as installed.

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

import gsap from 'gsap';
import {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useRef,
  useState,
  type ReactNode,
} from 'react';
import { cn } from '@/lib/utils';
import { useGsap } from '@/hooks/use-gsap';
import { useInView } from '@/hooks/use-in-view';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { clamp } from '@/lib/damp';
import { createRng } from '@/lib/prng';
import { mod } from '@/lib/wrap';

/* ==========================================================================
   Drag Marquee
   --------------------------------------------------------------------------
   A row that drifts forever, that you can grab and throw, and that scatters
   its cards while your hand is down.

   Three ideas hold it together.

   1. **One unwrapped number.** Everything — the idle drift, the drag, the
      throw — adds to a single accumulator that is never corrected. It walks
      off to -50000 and nobody cares. The wrap happens once, in the tween's
      `modifiers`, as `mod(x, period) - period`. Because a true modulo is
      total, a delta of 4.3 periods in one frame lands in exactly the right
      place; the usual `if (x < -period) x += period` correction only ever
      undoes one period, so a fast flick or a tab that was backgrounded for a
      second lands the row on the wrong tile and the seam tears.

   2. **The wrap is invisible on the near side.** For `x` already inside
      `[-period, 0)`, `mod(x, period) - period === x`, so the modifier is the
      identity in the range the row normally lives in and only bites when the
      accumulator leaves it. Nothing snaps at the boundary because the two
      sides of the boundary paint the same pixels.

   3. **Enough copies that the seam is never on screen.** `period` is the
      measured width of one copy of the children, gap included, and the row
      holds `ceil(hostWidth / period) + 1` copies. With `x` bounded to one
      period, the visible window can never run past the last copy.

   The throw needs no plugin: `power3.out` is `p(t) = 1 - (1 - t)^3`, so
   `p'(0) = 3`, and a tween of duration `D` that covers `Δ` leaves at `3Δ/D`
   px/s. Retargeting the follow tween to `x + v·D/3` on release therefore
   continues the gesture at the exact speed the hand left it.
   ========================================================================== */

export interface DragMarqueeProps {
  /** One tile per child. The set is duplicated as many times as it takes. */
  children: ReactNode;
  /** Idle drift in px/s. Positive drifts left; 0 parks the row. */
  speed?: number;
  /** Pixels between tiles. Applied inside a copy so it also spans the seam. */
  gap?: number;
  /** Seconds the row takes to catch up with the pointer. Also the throw time. */
  smoothing?: number;
  /** Scatter distance on press, as a percentage of each tile's own size. */
  scatter?: number;
  /** Half-range of the per-tile rotation on press, in degrees. */
  spin?: number;
  /** Tile scale while the pointer is down. */
  pressScale?: number;
  /** Seconds of the scatter in and back out. */
  scatterDuration?: number;
  /** Seeds the scatter. Same seed, same arrangement, on server and client. */
  seed?: number | string;
  className?: string;
  /** Applied to every tile — size the items here. */
  tileClassName?: string;
  'aria-label'?: string;
}

const SPEED = 60;
const GAP = 24;
const SMOOTHING = 0.5;
const SCATTER = 6;
const SPIN = 10;
const PRESS_SCALE = 0.95;
const SCATTER_DURATION = 0.5;

/** Initial slope of `power3.out`. `d/dt [1 - (1 - t)^3]` at t = 0. */
const POWER3_OUT_SLOPE = 3;
/** Pixels of travel before a press counts as a drag rather than a tap. */
const DRAG_THRESHOLD = 5;
/** Seconds of gesture history the release velocity is measured over. */
const VELOCITY_WINDOW = 0.07;
/** px/s. Above this a fling is a mis-measurement, not a throw. */
const MAX_VELOCITY = 4000;
/** Hard cap on how far one throw may carry, in pixels. */
const MAX_THROW = 1200;
/**
 * Two copies is the minimum that can hide a seam; eight is a guard against a
 * one-word tile in a wide hero producing hundreds of nodes.
 */
const MIN_COPIES = 2;
const MAX_COPIES = 8;

export function DragMarquee({
  children,
  speed = SPEED,
  gap = GAP,
  smoothing = SMOOTHING,
  scatter = SCATTER,
  spin = SPIN,
  pressScale = PRESS_SCALE,
  scatterDuration = SCATTER_DURATION,
  seed = 'drag-marquee',
  className,
  tileClassName,
  'aria-label': ariaLabel = 'Marquee',
}: DragMarqueeProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const copyRef = useRef<HTMLDivElement>(null);

  /** The unwrapped accumulator. Drift, drag and throw all add to this. */
  const rawRef = useRef(0);
  /** What the tween actually animates. Its `x` is already wrapped. */
  const proxyRef = useRef({ x: 0 });
  /** Measured width of one copy, gap included. Read live inside the modifier. */
  const periodRef = useRef(0);
  const xToRef = useRef<ReturnType<typeof gsap.quickTo> | null>(null);
  const scatterRef = useRef<gsap.core.Timeline | null>(null);
  const pressedRef = useRef(false);

  const [copies, setCopies] = useState(MIN_COPIES);

  const reduced = useReducedMotion();
  const hostSize = useResizeObserver(hostRef);
  const copySize = useResizeObserver(copyRef);
  const { inView } = useInView(hostRef);

  const tiles = Children.toArray(children);
  const perCopy = tiles.length;

  // #region critical
  /** The only place the track's transform is written. */
  const paint = useCallback(() => {
    const track = trackRef.current;
    if (!track) return;
    track.style.transform = `translate3d(${proxyRef.current.x.toFixed(2)}px, 0px, 0px)`;
  }, []);

  /**
   * Move the accumulator to `value`. With motion allowed this retargets the
   * follow tween — `quickTo` reuses one tween and one `PropTween` rather than
   * allocating a tween per frame — and the wrap happens in its modifier. With
   * reduced motion there is no tween, so the wrap is applied here instead.
   */
  const push = useCallback(
    (value: number) => {
      rawRef.current = value;
      const xTo = xToRef.current;
      if (xTo) {
        xTo(value);
        return;
      }
      const period = periodRef.current;
      proxyRef.current.x = period < 1 ? 0 : mod(value, period) - period;
      paint();
    },
    [paint],
  );

  /**
   * One follow tween for the life of the component. The wrap lives in
   * `modifiers`, which post-processes the value on its way to the target and
   * never feeds back: `resetTo` advances the tween's start from its own
   * `s + ratio * c`, not by re-reading `proxy.x`. Put the wrap anywhere else
   * — in `onUpdate`, say — and the next retarget interpolates from a value the
   * tween does not believe in, and the row lurches a full period.
   */
  useEffect(() => {
    const proxy = proxyRef.current;
    if (reduced) {
      xToRef.current = null;
      return;
    }

    const xTo = gsap.quickTo(proxy, 'x', {
      duration: smoothing,
      ease: 'power3',
      modifiers: {
        // Reads the ref, so a resize changes the period without rebuilding
        // the tween — rebuilding mid-throw would restart the ease from rest.
        x: (value: number) => {
          const period = periodRef.current;
          return period < 1 ? 0 : mod(value, period) - period;
        },
      },
      onUpdate: paint,
    });
    xToRef.current = xTo;

    return () => {
      xToRef.current = null;
      gsap.killTweensOf(proxy);
    };
  }, [reduced, smoothing, paint]);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    let pointerId: number | null = null;
    let lastX = 0;
    let pressX = 0;
    let pressY = 0;
    let moved = false;
    let swallowClick = false;
    /** Rolling window: the accumulator and clock at the start of the window. */
    let sampleRaw = 0;
    let sampleTime = 0;

    const now = () => performance.now() / 1000;

    const onPointerDown = (e: PointerEvent) => {
      // One pointer owns the row. A second finger arriving mid-throw would
      // otherwise reset the origin and the row would jump.
      if (pointerId !== null || !e.isPrimary) return;
      if (e.pointerType === 'mouse' && e.button !== 0) return;

      pointerId = e.pointerId;
      lastX = e.clientX;
      pressX = e.clientX;
      pressY = e.clientY;
      moved = false;
      sampleRaw = rawRef.current;
      sampleTime = now();
      pressedRef.current = true;

      // Capture on press, not once the threshold is crossed: by then the
      // pointer may already have left the element and `pointermove` would have
      // stopped arriving.
      host.setPointerCapture(e.pointerId);
      // Scatter on press, like a physical stack of cards being pinned — the
      // press, not the drag, is what the eye reads as "I have hold of this".
      scatterRef.current?.play();
    };

    const onPointerMove = (e: PointerEvent) => {
      if (pointerId !== e.pointerId) return;

      const dx = e.clientX - lastX;
      lastX = e.clientX;

      if (!moved) {
        if (Math.hypot(e.clientX - pressX, e.clientY - pressY) < DRAG_THRESHOLD) return;
        moved = true;
      }

      push(rawRef.current + dx);

      // Retire the velocity window once it is full. Measuring over a window
      // rather than off the last delta is what stops "drag, pause, lift" from
      // throwing the row: with no movement there are no events, the window
      // never advances, and the measured speed decays toward zero on its own.
      const t = now();
      if (t - sampleTime > VELOCITY_WINDOW) {
        sampleRaw = rawRef.current;
        sampleTime = t;
      }
    };

    /**
     * Idempotent. Called from `pointerup`, `pointercancel` and
     * `lostpointercapture`, all three of which can be the last event a gesture
     * produces, and at least one of which always arrives — including when the
     * pointer is released outside the window or the OS takes it away for a
     * gesture of its own. Guarding on `pointerId` makes the extra calls no-ops
     * instead of a double throw, and it is what stops a drag that ended
     * off-screen from leaving the row stuck mid-throw with the cards still
     * scattered.
     */
    const finish = (e: PointerEvent) => {
      if (pointerId !== e.pointerId) return;
      const id = pointerId;
      pointerId = null;
      pressedRef.current = false;

      const elapsed = now() - sampleTime;
      const velocity =
        moved && elapsed > 0
          ? clamp((rawRef.current - sampleRaw) / elapsed, -MAX_VELOCITY, MAX_VELOCITY)
          : 0;

      swallowClick = moved;
      moved = false;

      if (host.hasPointerCapture(id)) host.releasePointerCapture(id);
      scatterRef.current?.reverse();

      if (velocity !== 0) {
        // v·D/3 is the distance a `power3.out` of duration D covers when it
        // departs at v. Retargeting by exactly that hands the row over from
        // the finger without a change of speed.
        push(
          rawRef.current +
            clamp((velocity * smoothing) / POWER3_OUT_SLOPE, -MAX_THROW, MAX_THROW),
        );
      }
    };

    // Capture phase, ahead of any child's own handler. A drag ends with a
    // `click` on whatever happens to be under the pointer, so without this the
    // gesture that scrolls the row also activates a card.
    const onClick = (e: MouseEvent) => {
      if (!swallowClick) return;
      swallowClick = false;
      e.preventDefault();
      e.stopPropagation();
    };

    const onKeyDown = (e: KeyboardEvent) => {
      const direction = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
      if (direction === 0) return;
      e.preventDefault();
      const step = perCopy > 0 ? periodRef.current / perCopy : periodRef.current;
      push(rawRef.current + direction * step);
    };

    host.addEventListener('pointerdown', onPointerDown);
    host.addEventListener('pointermove', onPointerMove);
    host.addEventListener('pointerup', finish);
    host.addEventListener('pointercancel', finish);
    host.addEventListener('lostpointercapture', finish);
    host.addEventListener('click', onClick, true);
    host.addEventListener('keydown', onKeyDown);

    return () => {
      host.removeEventListener('pointerdown', onPointerDown);
      host.removeEventListener('pointermove', onPointerMove);
      host.removeEventListener('pointerup', finish);
      host.removeEventListener('pointercancel', finish);
      host.removeEventListener('lostpointercapture', finish);
      host.removeEventListener('click', onClick, true);
      host.removeEventListener('keydown', onKeyDown);
      if (pointerId !== null && host.hasPointerCapture(pointerId)) {
        host.releasePointerCapture(pointerId);
      }
      pointerId = null;
      pressedRef.current = false;
    };
  }, [push, smoothing, perCopy]);
  // #endregion critical

  /**
   * Measure one copy and decide how many there need to be.
   * `getBoundingClientRect`, not `offsetWidth`: the latter rounds to whole
   * pixels, and half a pixel of error in the period is half a pixel of gap
   * that only ever shows up at the seam.
   */
  useEffect(() => {
    const host = hostRef.current;
    const copy = copyRef.current;
    if (!host || !copy) return;

    const width = copy.getBoundingClientRect().width;
    if (width < 1) return;
    periodRef.current = width;

    setCopies(clamp(Math.ceil(host.clientWidth / width) + 1, MIN_COPIES, MAX_COPIES));
  }, [hostSize.width, copySize.width, perCopy, gap]);

  /**
   * Idle drift. `gsap.ticker` rather than a second rAF loop so the drift and
   * the follow tween are updated in the same tick — on separate loops the row
   * is one frame stale every other frame, which reads as a shimmer.
   */
  useEffect(() => {
    // Off-screen rows do not need to be redrawn 60 times a second. Where
    // there is no IntersectionObserver at all the gate opens rather than
    // closes — a missing API should cost battery, not the animation.
    const gated = typeof IntersectionObserver !== 'undefined' && !inView;
    if (reduced || speed === 0 || gated) return;

    const tick = (_time: number, deltaTime: number) => {
      // The drift stops while the pointer is down. Left running, the row
      // creeps out from under the finger, which is exactly the thing the
      // one-to-one tracking is there to avoid.
      if (pressedRef.current) return;
      push(rawRef.current - (deltaTime / 1000) * speed);
    };

    gsap.ticker.add(tick);
    return () => {
      gsap.ticker.remove(tick);
    };
  }, [reduced, speed, inView, push]);

  /**
   * The scatter. One paused timeline, played forwards on press and reversed on
   * release — never rebuilt per press. A press/release flag in the deps would
   * make `revert()` strip the inline transforms GSAP wrote, so the reverse
   * would start from the stylesheet default and animate nowhere.
   */
  useGsap(
    gsap,
    () => {
      const track = trackRef.current;
      if (!track || reduced || perCopy === 0) return;

      const nodes = Array.from(track.querySelectorAll<HTMLElement>('[data-kx-tile]'));
      if (nodes.length === 0) return;

      // Seeded, and indexed by position *within* a copy, so every copy of a
      // tile scatters identically. Per-node randomness makes the duplicate
      // visible the moment the cards move.
      const rng = createRng(seed);
      const spread = Array.from({ length: perCopy }, () => ({
        rotate: (rng() - 0.5) * 2 * spin,
        x: (rng() - 0.5) * 2 * scatter,
        y: (rng() - 0.5) * 2 * scatter,
      }));

      const at = (i: number) => spread[i % perCopy];

      const tl = gsap.timeline({ paused: true });
      tl.to(nodes, {
        rotate: (i: number) => at(i)?.rotate ?? 0,
        xPercent: (i: number) => at(i)?.x ?? 0,
        yPercent: (i: number) => at(i)?.y ?? 0,
        scale: pressScale,
        duration: scatterDuration,
        // `back.inOut` overshoots at both ends, so the cards spring apart and
        // spring back rather than easing politely in both directions.
        ease: 'back.inOut(3)',
      });
      scatterRef.current = tl;

      return () => {
        scatterRef.current = null;
      };
    },
    hostRef,
    [reduced, copies, perCopy, scatter, spin, pressScale, scatterDuration, seed],
  );

  const copyIndices = Array.from({ length: copies }, (_unused, i) => i);

  return (
    <div
      ref={hostRef}
      role="group"
      aria-label={ariaLabel}
      tabIndex={0}
      className={cn(
        // `touch-action: none`, not `pan-y`: on a phone the browser claims the
        // first 10 px of a horizontal drag for a scroll unless the element
        // says outright that it wants every pointer event.
        'relative h-full w-full touch-none select-none overflow-hidden',
        'cursor-grab active:cursor-grabbing focus-visible:outline-none',
        className,
      )}
    >
      <div
        ref={trackRef}
        className="flex h-full w-max flex-nowrap items-center will-change-transform"
      >
        {copyIndices.map((copyIndex) => (
          <div
            key={copyIndex}
            ref={copyIndex === 0 ? copyRef : undefined}
            // Only the first copy is real content. The rest are wallpaper, and
            // a screen reader that reads the list four times is a bug.
            aria-hidden={copyIndex > 0 || undefined}
            className="flex shrink-0 flex-nowrap items-center"
          >
            {tiles.map((tile, i) => (
              <div
                key={isValidElement(tile) && tile.key !== null ? tile.key : i}
                data-kx-tile=""
                // The gap is a trailing margin inside the copy rather than
                // `gap` on the track: a track-level gap sits *between* copies
                // too, so the measured copy width no longer equals the wrap
                // period and the seam gains one gap of drift per lap.
                style={{ marginRight: `${gap}px` }}
                className={cn('shrink-0', tileClassName)}
              >
                {tile}
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}
05

API

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

function DragMarquee(props: { children: ReactNode; speed?: number; gap?: number; smoothing?: number; scatter?: number; spin?: number; pressScale?: number; scatterDuration?: number; seed?: number | string; className?: string; tileClassName?: string; 'aria-label'?: string }): JSX.Element
Parameters
childrenReactNodeOne tile per child. The whole set is duplicated as many times as the width needs.
speednumber = 60Idle drift in px/s. Positive drifts left; 0 parks the row and skips the ticker entirely.
gapnumber = 24Pixels between tiles, applied inside a copy so the spacing also spans the seam.
smoothingnumber = 0.5Seconds the row takes to catch up with the pointer. Doubles as the throw duration.
scatternumber = 6Scatter distance on press, as a percentage of each tile's own size.
spinnumber = 10Half-range of the per-tile rotation on press, in degrees.
pressScalenumber = 0.95Tile scale while the pointer is down.
scatterDurationnumber = 0.5Seconds for the scatter out, and the same again back.
seednumber | string = 'drag-marquee'Seeds the scatter. The same seed gives the same arrangement on server and client.
classNamestringMerged onto the drag surface.
tileClassNamestringMerged onto every tile. Size the items here.
aria-labelstring = 'Marquee'Label for the focusable row, which also takes left and right arrow keys.
06

How it works

The technique in order, not the code in order.

  1. 01

    The row is N copies of the children inside a w-max flex track. period is the measured width of one copy — getBoundingClientRect().width, not offsetWidth, because the latter rounds and half a pixel of error is half a pixel of gap that only ever shows at the seam. The copy count is clamp(ceil(hostWidth / period) + 1, 2, 8), which is exactly enough that a translation bounded by one period can never expose the end of the track.

  2. 02

    Everything that moves the row adds to one number that is never corrected: the drift subtracts speed * dt, each pointermove adds dx, the release adds the throw distance. It walks off to -50000 and nothing cares, because the only consumer of it applies a modulo.

  3. 03

    That modulo lives in gsap.quickTo's modifiers, which post-processes the value on its way to the target. It cannot feed back: resetTo advances the tween's start from its own s + ratio * c rather than re-reading the target, so the wrapped value is output-only. Wrapping in onUpdate instead makes the next retarget interpolate from a value the tween does not believe in, and the row lurches a whole period.

  4. 04

    For x in [-period, 0), mod(x, period) === x + period, so mod(x, period) - period === x. The wrap is therefore the identity across the entire range the row normally occupies and only bites when the accumulator leaves it — the two sides of the boundary paint identical pixels, so there is nothing to snap.

  5. 05

    The gap is a trailing margin-right on each tile *inside* a copy rather than gap on the track. A track-level gap sits between copies too, so the measured copy width would no longer equal the wrap period and the seam would gain one gap of drift every lap.

  6. 06

    Release velocity is measured over a rolling ~70 ms window of (accumulator, time), then projected with the power3.out identity: p(t) = 1 - (1 - t)^3 has p'(0) = 3, so a tween of duration D covering delta departs at 3 * delta / D. Retargeting by v * D / 3 continues the gesture at exactly the speed the hand left, with the core library and no InertiaPlugin.

  7. 07

    The press scatter is one paused timeline built once and stored in a ref, play() on pointerdown and reverse() on the release path. Per-tile rotation and offset come from a seeded generator indexed by position *within* a copy, so every copy of a tile scatters identically and the duplication stays invisible while the cards move.

Load-bearing constants

Load-bearing constants
copies = clamp(ceil(hostWidth / period) + 1, 2, 8)The + 1 is the copy that covers the wrap: with the translation bounded to one period, the window can start one full period into the track and still needs hostWidth of content after it. Drop it and the tail of the row shows background for part of every lap. The floor of 2 is the minimum that can hide a seam at all; the ceiling of 8 stops a one-word tile in a wide hero from producing hundreds of nodes.
POWER3_OUT_SLOPE = 3The initial derivative of power3.out, and the divisor in delta = v * D / 3. Exact, not tuned: change the follow tween's ease and this changes with it — power2 needs 2, power4 needs 4. Leave it at 3 against a different ease and every throw is off by a constant factor.
smoothing = 0.5Seconds the row takes to catch up with the pointer, and the D in the throw projection. Under ~0.2 s the row is welded to the finger and the throw is over before it registers; over ~0.8 s the lag between hand and content is large enough to read as dropped frames rather than as weight.
speed = 60Idle drift in px/s. Below ~20 the row looks frozen at a glance and the affordance is lost; above ~120 the text is unreadable at rest, which defeats the point of putting words in it. At 0 the ticker is never registered at all, so a parked marquee costs nothing per frame.
VELOCITY_WINDOW = 0.07Seconds of gesture history the release speed is averaged over. Shorter is effectively the last delta, which is noisy at 120 Hz and gives visibly different throws for identical gestures; much longer averages away a deliberate slow-down before release, so the row flies off when the user meant to place it.
MAX_VELOCITY = 4000px/s the measured speed is clamped to. A coalesced pointer batch, a tab switch mid-drag or a synthetic event can report tens of thousands; without the clamp one bad sample throws the row several periods, and the wrap makes the result look random rather than fast.
MAX_THROW = 1200Pixels one throw may carry. Roughly a screen and a half — far enough that a hard flick feels unbounded, close enough that the content the user was looking at is still findable when it stops.
DRAG_THRESHOLD = 5Pixels of travel before a press becomes a drag. Below ~3 the tremor in a normal tap counts as a drag and every tap on a card is swallowed; above ~10 a small deliberate nudge does nothing at all.
scatter / spin = 6 (percent) / 10 (deg)Half-ranges for the per-tile offset and rotation on press. The offset is a percentage of each tile's own size, so mixed tile widths scatter proportionally instead of the small ones flying off. Past ~15% and ~25° the tiles overlap their neighbours and the row stops reading as a row; below ~3% and ~5° the press produces no visible acknowledgement.
ease: back.inOut(3) = 3The overshoot of the scatter, applied at both ends because the same timeline is played and reversed. It is what makes the cards spring apart and spring back rather than easing politely; at 1 the overshoot is invisible, past ~5 the return visibly overshoots the resting layout and the row wobbles after release.
07

Behaviour

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

Reduced motion

Dragging is unchanged — it is the user's own motion, and tracking it is the component. Everything the component would have animated by itself is dropped: the idle drift is never registered on the ticker, the follow tween is never created (so the row is written straight to the wrapped position, one-to-one with the pointer, and a release does not throw), and the scatter timeline is not built, so pressing moves nothing but the row. Arrow keys still step one tile, instantly.

Cleanup

Removes pointerdown, pointermove, pointerup, pointercancel, lostpointercapture, the capture-phase click and keydown; releases pointer capture if a gesture is still live at unmount; removes the drift callback from gsap.ticker; kills the tweens on the proxy object so an in-flight throw cannot call onUpdate against a detached track; and reverts the gsap.context holding the scatter timeline, which also restores the tiles' inline transforms. Both shared resize observers and the shared intersection observer unregister.

Definition of done

The row drifts on its own. Grab it and it follows your hand exactly, with no creep in the drift direction, and the cards tilt and shrink apart the instant you press — then settle back when you let go. Flick and release: it coasts on at the speed you left, slows, and rejoins the drift with no visible seam or jump. Drag several screens' worth in one fast swipe and the tiles stay in order — nothing teleports. Watch one tile go off the left edge and it reappears on the right at the same spacing as every other gap. Release the pointer outside the browser window and the row keeps drifting instead of freezing scattered. Release over a card and no click reaches it; tap without moving and it does. Resize the container narrower and wider — the seam never appears. Scroll it out of view and the drift stops until it is back.

Pitfalls

  • Correcting the offset with if (x < -period) x += period instead of a modulo. It only ever undoes one period, so a fast flick, a coalesced pointer batch, or a tab that was backgrounded for a second leaves the row several periods out and it jumps to the wrong tile. mod is total: any magnitude, one step, right answer.
  • Applying the wrap in onUpdate rather than in modifiers. The tween then interpolates from a start value it computed internally toward a target the wrap has already moved, so the next retarget travels a whole extra period. modifiers is post-processing on the way out and never feeds back into the tween's own bookkeeping.
  • Putting the gap on the track with gap or space-x. It lands between copies as well as between tiles, so the measured copy width no longer equals the wrap period; the seam gains one gap per lap and slowly slides out of register. The gap belongs inside the copy, as a trailing margin on every tile.
  • Measuring the period with offsetWidth. It rounds to whole pixels, and a sub-pixel error in the period is a permanent sub-pixel misalignment at the seam that shows as a hairline flicker once per lap.
  • Hard-coding two copies. Two is enough only while one copy is at least as wide as the container; a short tile set in a wide hero runs out of content mid-lap and the row ends in empty space. The count has to come from the measured width.
  • Randomising the scatter per DOM node. The duplicates are the whole trick, and the instant the cards move with different rotations the eye reads them as different content — the seam becomes obvious. Index the seed by position within a copy, so copy 3 tile 5 matches copy 1 tile 5.
  • Rebuilding the scatter timeline per press, or putting the pressed flag in the useGsap deps. revert() strips the inline transforms GSAP wrote, so the reverse starts from the untouched stylesheet values and animates nowhere — the cards stay scattered. One paused timeline, play() and reverse() from the pointer handlers.
  • Leaving the idle drift running during a drag. The row creeps out from under the finger, which is precisely what one-to-one tracking exists to prevent; over a slow careful drag the divergence is several tiles.
  • Calling setPointerCapture only after the drag threshold is crossed. A fast gesture has already left the element by then, pointermove stops arriving, and the row sticks at the offset it had when the pointer crossed the boundary.
  • Handling only pointerup. Release outside the window, or let the OS claim the gesture, and no pointerup arrives: the row stays captured, the drift never resumes, and the cards stay scattered mid-throw. pointercancel and lostpointercapture route to the same idempotent finish, guarded on the stored pointerId so the duplicate is a no-op rather than a second throw.
  • Letting the click through after a drag. The gesture ends with a click on whichever card is under the pointer, so dragging the row also opens a project. It is swallowed in the capture phase, once, and only when the gesture actually moved.

Notes

  • The follow tween is retargeted, not recreated: gsap.quickTo keeps one tween and one PropTween alive and calls resetTo on it, so a marquee dragged for ten seconds allocates nothing per frame.
  • The drift is registered on gsap.ticker rather than a second rAF loop, so the target and the tween that chases it advance in the same tick. On separate loops the row is one frame stale every other frame, which reads as a faint shimmer at speed.
  • Because the drift keeps retargeting the same tween after release, the throw decays exponentially with a time constant of smoothing / 3 rather than tracing a literal power3 curve. The departure speed is still exactly the hand's, which is the part the eye judges.
  • Only the first copy is exposed to assistive technology; the rest carry aria-hidden, so a screen reader reads the list once rather than four times.