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 {
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",
}}
/>
);
}