A comet of spring-chained dots that trail the pointer inside its surface, whipping and settling with real physics and dissolving on leave.
npx shadcn@latest add @paragon/cursor-trail"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}
/>
)}
</>
);
}