Cursor Trail
Cursors & Pointer

Cursor Trail

A comet of spring-chained dots that trail the pointer inside its surface, whipping and settling with real physics and dissolving on leave.

Install

npx shadcn@latest add @paragon/cursor-trail

cursor-trail.tsx

"use client";

import * as React from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * CursorTrail — a comet of trailing dots that chase the pointer inside the
 * surface. The dots form a true chain: each one is a spring bound to the dot
 * ahead of it (not to the pointer), so the comet whips around corners and
 * settles with real accumulated physics. Dots shrink and fade toward the tail,
 * the whole comet contracts springily while the pointer is pressed, and on
 * entry the entire chain teleports to the entry point — no streak in from
 * offscreen. Wrap any content: `<CursorTrail>…surface…</CursorTrail>`.
 *
 * The native cursor is hidden only inside the surface; the trail layer is
 * pointer-events-none so clicks pass through. Gated on fine-pointer devices;
 * under `prefers-reduced-motion` the native cursor is kept and nothing renders.
 */
export interface CursorTrailProps extends React.ComponentProps<"div"> {
  /** Dot color. Defaults to the primary token. */
  color?: string;
  /** Number of dots in the comet (head + tail). */
  length?: number;
  /** Head dot diameter in px; tail dots scale down from here. */
  size?: number;
  /** Keep the leading dot crisp (no spring lag on the head). */
  sharpHead?: boolean;
}

const PRESS_SPRING: SpringOptions = { stiffness: 500, damping: 30, mass: 0.5 };

export function CursorTrail({
  color = "var(--color-primary)",
  length = 14,
  size = 14,
  sharpHead = true,
  className,
  children,
  ...props
}: CursorTrailProps) {
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);
  const [inside, setInside] = React.useState(false);
  const insideRef = React.useRef(false);
  const reduced = useReducedMotion();
  const active = fine && !reduced;

  const count = Math.min(32, Math.max(1, Math.round(length)));

  const x = useMotionValue(-9999);
  const y = useMotionValue(-9999);
  const press = useMotionValue(1);

  // Every chain link registers a teleport function so entry can snap the whole
  // comet to the pointer at once.
  const jumpFns = React.useRef(new Set<(px: number, py: number) => void>());
  const registerJump = React.useCallback(
    (fn: (px: number, py: number) => void) => {
      jumpFns.current.add(fn);
      return () => {
        jumpFns.current.delete(fn);
      };
    },
    [],
  );

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    const el = hostRef.current;
    if (!el || !active) return;

    const enterAt = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      const px = e.clientX - rect.left;
      const py = e.clientY - rect.top;
      x.jump(px);
      y.jump(py);
      for (const jump of jumpFns.current) jump(px, py);
      insideRef.current = true;
      setInside(true);
    };

    const onMove = (e: PointerEvent) => {
      if (!insideRef.current) {
        enterAt(e);
        return;
      }
      const rect = el.getBoundingClientRect();
      x.set(e.clientX - rect.left);
      y.set(e.clientY - rect.top);
    };
    const onEnter = (e: PointerEvent) => enterAt(e);
    const onLeave = () => {
      insideRef.current = false;
      setInside(false);
      animate(press, 1, PRESS_SPRING);
    };
    const onDown = () => animate(press, 0.82, PRESS_SPRING);
    const onUp = () => animate(press, 1, PRESS_SPRING);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    el.addEventListener("pointerdown", onDown);
    el.addEventListener("pointerup", onUp);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
      el.removeEventListener("pointerdown", onDown);
      el.removeEventListener("pointerup", onUp);
    };
  }, [active, press, x, y]);

  return (
    <div
      ref={hostRef}
      className={cn(
        "relative overflow-hidden",
        active && "[&_*]:cursor-none",
        className,
      )}
      style={active ? { cursor: "none" } : undefined}
      {...props}
    >
      {children}

      {active && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 z-50"
          style={{ opacity: inside ? 1 : 0, transition: "opacity 200ms ease" }}
        >
          <ChainLink
            key={count}
            index={0}
            total={count}
            sourceX={x}
            sourceY={y}
            color={color}
            size={size}
            sharpHead={sharpHead}
            press={press}
            registerJump={registerJump}
          />
        </div>
      )}
    </div>
  );
}

interface ChainLinkProps {
  index: number;
  total: number;
  sourceX: MotionValue<number>;
  sourceY: MotionValue<number>;
  color: string;
  size: number;
  sharpHead: boolean;
  press: MotionValue<number>;
  registerJump: (fn: (px: number, py: number) => void) => () => void;
}

/**
 * One comet segment. Springs toward the segment ahead of it and renders the
 * next segment with its own sprung position as the source — a real linked
 * chain, so lag accumulates naturally down the tail.
 */
function ChainLink({
  index,
  total,
  sourceX,
  sourceY,
  color,
  size,
  sharpHead,
  press,
  registerJump,
}: ChainLinkProps) {
  const t = total <= 1 ? 0 : index / (total - 1);
  // Per-link spring: slightly softer toward the tail so the whip loosens.
  const spring = React.useMemo<SpringOptions>(
    () => ({ stiffness: 700 - t * 240, damping: 30, mass: 0.4 + t * 0.25 }),
    [t],
  );
  const sx = useSpring(sourceX, spring);
  const sy = useSpring(sourceY, spring);
  const crisp = index === 0 && sharpHead;
  const outX = crisp ? sourceX : sx;
  const outY = crisp ? sourceY : sy;

  React.useEffect(
    () =>
      registerJump((px, py) => {
        sx.jump(px);
        sy.jump(py);
      }),
    [registerJump, sx, sy],
  );

  const shrink = 1 - t * 0.72;
  const dotSize = size * shrink;
  const opacity = 1 - t * 0.55;
  // Press contracts the comet — strongest at the head, gentle at the tail.
  const dotScale = useTransform(press, (p) => 1 - (1 - p) * (1 - t * 0.5));

  return (
    <>
      <motion.span
        className="absolute top-0 left-0 rounded-full"
        style={{
          x: outX,
          y: outY,
          scale: dotScale,
          width: dotSize,
          height: dotSize,
          marginLeft: -dotSize / 2,
          marginTop: -dotSize / 2,
          background: color,
          opacity,
          zIndex: total - index,
        }}
      />
      {index + 1 < total && (
        <ChainLink
          index={index + 1}
          total={total}
          sourceX={outX}
          sourceY={outY}
          color={color}
          size={size}
          sharpHead={sharpHead}
          press={press}
          registerJump={registerJump}
        />
      )}
    </>
  );
}