bottega

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.


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-trail

Usage

Usagetsx
<CursorTrail />

Props

PropTypeDefaultDescription
dotCountnumber6Number of trailing dots including the lead dot. Default 6, clamped to 12.
springPresetenumtightPhysics 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.
dotSizeBasenumber10Lead dot diameter in px. Trailing dots decay by 0.78 per step. Default 10.
idleMsnumber400Milliseconds of stillness before idle dissolution triggers. Default 400.
classNamestringApplied to the container wrapper.
childrenReact.ReactNodeContent 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