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 {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * CursorTrail — a comet of trailing dots that chase the pointer inside the
 * surface. Each dot is a spring bound to the one ahead of it, so the chain
 * whips and settles with real physics; dots shrink and fade toward the tail and
 * dissolve when the pointer leaves. 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 reduced motion the chain collapses to a single static dot.
 */
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 HEAD_SPRING: SpringOptions = { stiffness: 520, damping: 34, 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 reduced = useReducedMotion();

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

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

  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 || !fine) return;
    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      x.set(e.clientX - rect.left);
      y.set(e.clientY - rect.top);
    };
    const onEnter = () => setInside(true);
    const onLeave = () => setInside(false);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [fine, x, y]);

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

      {fine && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 z-50"
          style={{ opacity: inside ? 1 : 0, transition: "opacity 200ms ease" }}
        >
          {Array.from({ length: reduced ? 1 : count }).map((_, i) => (
            <TrailDot
              key={i}
              index={i}
              total={count}
              sourceX={x}
              sourceY={y}
              color={color}
              size={size}
              reduced={!!reduced}
              sharpHead={sharpHead}
            />
          ))}
        </div>
      )}
    </div>
  );
}

interface TrailDotProps {
  index: number;
  total: number;
  sourceX: ReturnType<typeof useMotionValue<number>>;
  sourceY: ReturnType<typeof useMotionValue<number>>;
  color: string;
  size: number;
  reduced: boolean;
  sharpHead: boolean;
}

function TrailDot({
  index,
  total,
  sourceX,
  sourceY,
  color,
  size,
  reduced,
  sharpHead,
}: TrailDotProps) {
  // Each dot lags a bit more than the one ahead of it: softer springs down the
  // tail produce the comet whip. Deterministic — no random.
  const t = total <= 1 ? 0 : index / (total - 1);
  const stiffness = reduced ? 1000 : 480 - t * 360;
  const damping = reduced ? 40 : 30 - t * 8;
  const spring: SpringOptions = { stiffness, damping, mass: 0.5 + t * 0.6 };

  const noSpring = reduced || (sharpHead && index === 0);
  const sx = useSpring(sourceX, spring);
  const sy = useSpring(sourceY, spring);
  const px = noSpring ? sourceX : sx;
  const py = noSpring ? sourceY : sy;

  const scale = 1 - t * 0.72;
  const dotSize = size * scale;
  const opacity = reduced ? 1 : 1 - t * 0.55;

  return (
    <motion.span
      className="absolute top-0 left-0 rounded-full"
      style={{
        x: px,
        y: py,
        width: dotSize,
        height: dotSize,
        marginLeft: -dotSize / 2,
        marginTop: -dotSize / 2,
        background: color,
        opacity,
        zIndex: total - index,
        mixBlendMode: "normal",
      }}
    />
  );
}