experimental
Cursor Trail
A container-scoped cursor trail that replaces the native pointer inside a region with a lead dot and a spring-physics chain of trailing dots. Two differentiators: chromatic taper (geometric size + opacity decay so the chain reads as one organism whispering to a tail) and idle dissolution (after the pointer stills, dots scatter-fade with a staggered ease-out, then reform instantly on next move). In-view gated via IntersectionObserver; coarse-pointer and reduced-motion users get the native cursor with zero motion.
Move your cursor here
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/cursor-trailUsage
Usagetsx
<CursorTrail />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| dotCount | number | 6 | Number of trailing dots including the lead dot. Default 6, clamped to 12. |
| springPreset | enum | tight | Physics preset. tight = crisp snap, loose = lazy drift. Default 'tight'. |
| springConfig | { stiffness: number; damping: number; mass: number; } | — | Spring override used when springPreset is 'custom'. mass defaults to 1. |
| dotSizeBase | number | 10 | Lead dot diameter in px. Trailing dots decay by 0.78 per step. Default 10. |
| idleMs | number | 400 | Milliseconds of stillness before idle dissolution triggers. Default 400. |
| className | string | Applied to the container wrapper. | |
| children | React.ReactNode | — | Content scoped by the trail region. |
Source
cursor-trail.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import type { MotionValue } from "motion/react";
import { useMotionValue, useSpring, useTransform, useMotionValueEvent } from "motion/react";
import styles from "./cursor-trail.module.css";
type SpringPreset = "tight" | "loose" | "custom";
export interface CursorTrailProps {
/** Number of trailing dots including the lead dot. Default 6, clamped to 12. */
dotCount?: number;
/** Physics preset. tight = crisp snap, loose = lazy drift. Default 'tight'. */
springPreset?: SpringPreset;
/** Spring override used when springPreset is 'custom'. mass defaults to 1. */
springConfig?: { stiffness: number; damping: number; mass: number };
/** Lead dot diameter in px. Trailing dots decay by 0.78 per step. Default 10. */
dotSizeBase?: number;
/** Milliseconds of stillness before idle dissolution triggers. Default 400. */
idleMs?: number;
/** Applied to the container wrapper. */
className?: string;
/** Content scoped by the trail region. */
children?: React.ReactNode;
}
const PRESETS: Record<"tight" | "loose", { stiffness: number; damping: number; mass: number }> = {
tight: { stiffness: 500, damping: 50, mass: 1 },
loose: { stiffness: 120, damping: 28, mass: 1 },
};
const MAX_DOTS = 12;
const SIZE_DECAY = 0.78; // geometric size taper per dot index
const OPACITY_DECAY = 0.72; // geometric opacity taper — tail vanishes twice over
const STIFFNESS_DECAY = 0.82; // earlier dots crisp, tail dots dreamy
const DAMPING_GROWTH = 1.05;
const SCATTER_RANGE = 16; // total px spread for idle scatter (±8px)
const SCATTER_STAGGER = 0.03; // 30ms per index
/**
* A single trailing dot. It springs toward a source point (the lead dot's
* source is the raw pointer; each subsequent dot's source is the prior dot's
* resolved spring position). Stiffness/damping are staggered per index so the
* chain reads as one tapering organism rather than N identical copies.
*
* Timings here are SECONDS (Motion JS), distinct from the --duration CSS token
* (~300ms for UI transitions). These bespoke scene durations are legitimate
* numeric literals.
*/
function TrailDot({
index,
sourceX,
sourceY,
size,
opacity,
spring,
idle,
scatter,
onResolve,
isLead,
}: {
index: number;
sourceX: MotionValue<number>;
sourceY: MotionValue<number>;
size: number;
opacity: number;
spring: { stiffness: number; damping: number; mass: number };
idle: boolean;
scatter: { x: number; y: number };
onResolve: (x: number, y: number) => void;
isLead: boolean;
}) {
const x = useSpring(sourceX, spring);
const y = useSpring(sourceY, spring);
// Publish this dot's resolved position so the next dot can chase it.
useMotionValueEvent(x, "change", (v) => onResolve(v, y.get()));
useMotionValueEvent(y, "change", (v) => onResolve(x.get(), v));
// Outer span owns POSITION (Motion x/y from the spring, centered on the
// tracked point). Inner span owns the VISUAL state (opacity + idle scatter)
// so the two transform sources never collide on the same element.
const half = size / 2;
const translateX = useTransform(x, (v) => v - half);
const translateY = useTransform(y, (v) => v - half);
return (
<motion.span
aria-hidden="true"
inert
className={styles.dotWrap}
style={{ x: translateX, y: translateY, width: size, height: size }}
>
<motion.span
className={[styles.dot, isLead ? styles.lead : ""].filter(Boolean).join(" ")}
initial={{ opacity: 0, scale: 0 }}
animate={
idle
? // staggered scatter-fade: drift apart, then vanish
{ opacity: 0, scale: 1, x: scatter.x, y: scatter.y }
: // active / reform: settle at rest with the per-index opacity
{ opacity, scale: 1, x: 0, y: 0 }
}
transition={
idle
? { duration: 0.5, ease: "easeOut", delay: index * SCATTER_STAGGER }
: {
opacity: { duration: 0.2 },
scale: { duration: 0.2 },
x: { duration: 0.2 },
y: { duration: 0.2 },
}
}
/>
</motion.span>
);
}
export function CursorTrail({
dotCount = 6,
springPreset = "tight",
springConfig,
dotSizeBase = 10,
idleMs = 400,
className = "",
children,
}: CursorTrailProps) {
const prefersReducedMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
const [inView, setInView] = useState(false);
const [idle, setIdle] = useState(false);
// Seeded scatter offsets, generated post-mount (no Math.random at first paint).
const [scatter, setScatter] = useState<{ x: number; y: number }[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const count = Math.max(1, Math.min(MAX_DOTS, Math.floor(dotCount)));
const base =
springPreset === "custom" && springConfig
? springConfig
: PRESETS[springPreset === "loose" ? "loose" : "tight"];
// Stable source MotionValues for the whole chain. Index 0 = raw pointer; each
// subsequent index holds the prior dot's resolved position, published back by
// that dot. These exist from first mount so the spring chain never re-binds.
const sources = useRef<Array<{ x: MotionValue<number>; y: MotionValue<number> }>>([]);
// Lazily create the pointer source + relay sources once.
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const relayX0 = useMotionValue(0);
const relayX1 = useMotionValue(0);
const relayX2 = useMotionValue(0);
const relayX3 = useMotionValue(0);
const relayX4 = useMotionValue(0);
const relayX5 = useMotionValue(0);
const relayX6 = useMotionValue(0);
const relayX7 = useMotionValue(0);
const relayX8 = useMotionValue(0);
const relayX9 = useMotionValue(0);
const relayX10 = useMotionValue(0);
const relayY0 = useMotionValue(0);
const relayY1 = useMotionValue(0);
const relayY2 = useMotionValue(0);
const relayY3 = useMotionValue(0);
const relayY4 = useMotionValue(0);
const relayY5 = useMotionValue(0);
const relayY6 = useMotionValue(0);
const relayY7 = useMotionValue(0);
const relayY8 = useMotionValue(0);
const relayY9 = useMotionValue(0);
const relayY10 = useMotionValue(0);
// relays[i] receives dot i's resolved position; it feeds dot i+1's source.
const relays: Array<{ x: MotionValue<number>; y: MotionValue<number> }> = [
{ x: relayX0, y: relayY0 },
{ x: relayX1, y: relayY1 },
{ x: relayX2, y: relayY2 },
{ x: relayX3, y: relayY3 },
{ x: relayX4, y: relayY4 },
{ x: relayX5, y: relayY5 },
{ x: relayX6, y: relayY6 },
{ x: relayX7, y: relayY7 },
{ x: relayX8, y: relayY8 },
{ x: relayX9, y: relayY9 },
{ x: relayX10, y: relayY10 },
];
if (sources.current.length === 0) {
sources.current = [{ x: pointerX, y: pointerY }, ...relays];
}
const prefersReducedMotionResolved = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !prefersReducedMotionResolved;
useEffect(() => {
setMounted(true);
setScatter(
Array.from({ length: MAX_DOTS }, () => ({
x: (Math.random() - 0.5) * SCATTER_RANGE,
y: (Math.random() - 0.5) * SCATTER_RANGE,
})),
);
}, []);
// Gate spring work behind viewport visibility — no offscreen calculations.
useEffect(() => {
if (!animate) return;
const el = containerRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [animate]);
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!animate || !inView || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
pointerX.set(e.clientX - rect.left);
pointerY.set(e.clientY - rect.top);
if (idle) setIdle(false);
if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => setIdle(true), idleMs);
}
function handlePointerLeave() {
if (idleTimer.current) clearTimeout(idleTimer.current);
setIdle(true);
}
useEffect(() => {
return () => {
if (idleTimer.current) clearTimeout(idleTimer.current);
};
}, []);
const showTrail = animate && inView && scatter.length > 0;
return (
<div
ref={containerRef}
role="presentation"
tabIndex={0}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className={[
styles.container,
// Hide the native cursor ONLY when the trail is actually rendered.
// Pre-mount and reduced/null-motion states keep the system cursor so
// users are never left with no pointer and no dots to replace it.
showTrail ? styles.cursorHidden : "",
"rounded-[var(--radius)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
]
.filter(Boolean)
.join(" ")}
>
{children}
{showTrail && (
<div className={styles.layer} aria-hidden="true">
{Array.from({ length: count }).map((_, i) => {
const size = dotSizeBase * SIZE_DECAY ** i;
const opacity = 1 * OPACITY_DECAY ** i;
const spring = {
stiffness: base.stiffness * STIFFNESS_DECAY ** i,
damping: base.damping * DAMPING_GROWTH ** i,
mass: base.mass ?? 1,
};
const src = sources.current[i];
// The last rendered dot has no follower, so it has no relay to feed
// (relays has MAX_DOTS-1 entries). Guard the publish so a full chain
// (dotCount === MAX_DOTS) doesn't dereference an undefined relay.
const relay = relays[i];
return (
<TrailDot
key={i}
index={i}
isLead={i === 0}
sourceX={src.x}
sourceY={src.y}
size={size}
opacity={opacity}
spring={spring}
idle={idle}
scatter={scatter[i] ?? { x: 0, y: 0 }}
onResolve={(x, y) => {
if (!relay) return;
relay.x.set(x);
relay.y.set(y);
}}
/>
);
})}
</div>
)}
</div>
);
}
Dependencies
- motion
- @bottega/tokens