cursor preview.

A preview card that trails the pointer down a list with a little physical lag, and follows keyboard focus as well.

react / motion / hover / a11y

Loading component...

live component, try it

what to expect.

Move the pointer over the rows
Tab through the rows to see it follow focus
The card flips sides near the right edge

how it works.

The easing is 1 - exp(-dt/tau) rather than a fixed slice of the remaining gap each frame, so the trail feels the same at 30fps and at 144. The loop parks itself the moment it arrives, which means a pointer resting on one row schedules no frames at all. It follows focus as well as the pointer, because a preview that only exists for a mouse does not exist for anyone arriving by keyboard. On a touch screen there is no pointer to follow, so the demo walks the rows with a drawn cursor instead.

the component.

const DEFAULT_TAU = 90;
const MAX_FRAME_MS = 50;
const EPSILON = 0.1;

export interface FollowOptions {
  onFrame: (x: number, y: number) => void;
  tau?: number;
}

export interface FollowHandle {
  readonly x: number;
  readonly y: number;
  readonly running: boolean;
  target(x: number, y: number): void;
  snap(x: number, y: number): void;
  step(dtMs: number): void;
  start(): void;
  kill(): void;
}

export function createFollow({ onFrame, tau = DEFAULT_TAU }: FollowOptions): FollowHandle {
  let x = 0;
  let y = 0;
  let tx = 0;
  let ty = 0;
  let placed = false;
  let raf = 0;
  let last = 0;
  let dead = false;

  const settled = () => Math.abs(tx - x) < EPSILON && Math.abs(ty - y) < EPSILON;

  function step(dtMs: number): void {
    if (dead) return;
    // Frame-rate independent easing: same feel at 30fps and 144fps.
    const k = 1 - Math.exp(-Math.min(dtMs, MAX_FRAME_MS) / tau);
    x += (tx - x) * k;
    y += (ty - y) * k;
    // Park the loop on arrival so a resting pointer schedules no frames.
    if (settled()) {
      x = tx;
      y = ty;
      if (raf) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    }
    onFrame(x, y);
  }

  function frame(now: number): void {
    const dt = now - last;
    last = now;
    raf = 0;
    step(dt);
    if (!dead && !settled()) raf = requestAnimationFrame(frame);
  }

  function wake(): void {
    if (raf || dead || settled()) return;
    last = performance.now();
    raf = requestAnimationFrame(frame);
  }

  return {
    get x() {
      return x;
    },
    get y() {
      return y;
    },
    get running() {
      return raf !== 0;
    },
    target(nx, ny) {
      if (dead) return;
      tx = nx;
      ty = ny;
      // First target places the point instead of easing in from the origin.
      if (!placed) {
        placed = true;
        x = nx;
        y = ny;
        onFrame(x, y);
        return;
      }
      wake();
    },
    snap(nx, ny) {
      if (dead) return;
      placed = true;
      tx = nx;
      ty = ny;
      x = nx;
      y = ny;
      onFrame(x, y);
    },
    step,
    start: wake,
    kill() {
      dead = true;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    },
  };
}