Web InnoventixFreeCode

Trail Cursor

Original · free

cursor trail effect

byWeb InnoventixReact + Tailwind
curtrailcursors
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/cur-trail.json
cur-trail.tsx
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { useReducedMotion } from "motion/react";

const MAX_SEGMENTS = 30;

type TrailStyle = "comet" | "dots" | "glow" | "ribbon";
type PaletteKey = "indigo" | "emerald" | "rose" | "amber" | "sky";

interface PaletteDef {
  key: PaletteKey;
  label: string;
  core: string;
  edge: string;
}

const PALETTES: PaletteDef[] = [
  { key: "indigo", label: "Indigo", core: "#6366f1", edge: "#a5b4fc" },
  { key: "emerald", label: "Emerald", core: "#10b981", edge: "#6ee7b7" },
  { key: "rose", label: "Rose", core: "#f43f5e", edge: "#fda4af" },
  { key: "amber", label: "Amber", core: "#f59e0b", edge: "#fcd34d" },
  { key: "sky", label: "Sky", core: "#0ea5e9", edge: "#7dd3fc" },
];

const STYLES: { key: TrailStyle; label: string; hint: string }[] = [
  { key: "comet", label: "Comet", hint: "Tapered head to tail" },
  { key: "dots", label: "Dots", hint: "Even beaded chain" },
  { key: "glow", label: "Glow", hint: "Soft blurred orbs" },
  { key: "ribbon", label: "Ribbon", hint: "One connected stroke" },
];

const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;

interface SwitchProps {
  checked: boolean;
  onChange: (next: boolean) => void;
  label: string;
  hint: string;
}

function Switch({ checked, onChange, label, hint }: SwitchProps) {
  const id = useId();
  return (
    <div className="flex items-center justify-between gap-4">
      <span className="min-w-0">
        <span
          id={id}
          className="block text-sm font-medium text-slate-800 dark:text-slate-100"
        >
          {label}
        </span>
        <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
          {hint}
        </span>
      </span>
      <button
        type="button"
        role="switch"
        aria-checked={checked}
        aria-labelledby={id}
        onClick={() => onChange(!checked)}
        className={`relative inline-flex h-6 w-11 flex-none items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
          checked
            ? "bg-indigo-600 dark:bg-indigo-500"
            : "bg-slate-300 dark:bg-slate-700"
        }`}
      >
        <span
          className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform duration-200 ${
            checked ? "translate-x-5" : "translate-x-0.5"
          }`}
        />
      </button>
    </div>
  );
}

export default function CurTrail() {
  const reduce = !!useReducedMotion();

  const [enabled, setEnabled] = useState(true);
  const [style, setStyle] = useState<TrailStyle>("comet");
  const [palette, setPalette] = useState<PaletteKey>("indigo");
  const [length, setLength] = useState(22);
  const [sparkle, setSparkle] = useState(true);
  const [keyboardActive, setKeyboardActive] = useState(false);

  const containerRef = useRef<HTMLDivElement | null>(null);
  const dotRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const innerRefs = useRef<(HTMLSpanElement | null)[]>([]);
  const polyRef = useRef<SVGPolylineElement | null>(null);

  const pointsRef = useRef(
    Array.from({ length: MAX_SEGMENTS }, () => ({ x: 0, y: 0 })),
  );
  const targetRef = useRef({ x: 0, y: 0 });
  const countRef = useRef(length);

  const hintId = useId();
  const lenId = useId();
  const lenHintId = useId();
  const styleName = useId();
  const palName = useId();

  useEffect(() => {
    countRef.current = length;
  }, [length]);

  // Seed all segments to the canvas centre so the trail never flashes at 0,0.
  useEffect(() => {
    if (!enabled) return;
    const el = containerRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const cx = r.width / 2;
    const cy = r.height / 2;
    targetRef.current = { x: cx, y: cy };
    for (let i = 0; i < MAX_SEGMENTS; i++) {
      pointsRef.current[i].x = cx;
      pointsRef.current[i].y = cy;
      const outer = dotRefs.current[i];
      if (outer) outer.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
    }
  }, [enabled]);

  // Chain-eased follow loop: each segment eases toward the one ahead of it.
  useEffect(() => {
    if (!enabled) return;
    let raf = 0;
    const loop = () => {
      const pts = pointsRef.current;
      const t = targetRef.current;
      const ease = 0.3;
      pts[0].x += (t.x - pts[0].x) * ease;
      pts[0].y += (t.y - pts[0].y) * ease;
      for (let i = 1; i < MAX_SEGMENTS; i++) {
        pts[i].x += (pts[i - 1].x - pts[i].x) * ease;
        pts[i].y += (pts[i - 1].y - pts[i].y) * ease;
      }
      for (let i = 0; i < MAX_SEGMENTS; i++) {
        const outer = dotRefs.current[i];
        if (outer) {
          outer.style.transform = `translate3d(${pts[i].x}px, ${pts[i].y}px, 0)`;
        }
      }
      const poly = polyRef.current;
      if (poly) {
        const count = countRef.current;
        let d = "";
        for (let i = 0; i < count; i++) {
          d += `${pts[i].x.toFixed(1)},${pts[i].y.toFixed(1)} `;
        }
        poly.setAttribute("points", d.trim());
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [enabled]);

  // Per-segment visual styling (size / colour / opacity) — recomputed only when
  // the look changes, so the rAF loop stays limited to cheap transforms.
  useEffect(() => {
    const pal = PALETTES.find((p) => p.key === palette) ?? PALETTES[0];
    for (let i = 0; i < MAX_SEGMENTS; i++) {
      const inner = innerRefs.current[i];
      if (!inner) continue;
      const active = enabled && style !== "ribbon" && i < length;
      if (!active) {
        inner.style.opacity = "0";
        inner.classList.remove("curtrail-twinkle");
        continue;
      }
      const p = length <= 1 ? 0 : i / (length - 1);
      let size = 10;
      let opacity = 0.6;
      let blur = 0;
      let shadow = "none";
      let bg = pal.core;
      if (style === "comet") {
        size = lerp(24, 4, p);
        opacity = lerp(1, 0.05, p);
        bg = `radial-gradient(circle at 32% 30%, ${pal.edge}, ${pal.core})`;
        shadow = `0 0 ${lerp(22, 2, p).toFixed(1)}px ${pal.core}80`;
      } else if (style === "dots") {
        size = lerp(11, 5, p);
        opacity = lerp(0.95, 0.12, p);
        bg = pal.core;
        shadow = `0 0 8px ${pal.core}55`;
      } else if (style === "glow") {
        size = lerp(34, 10, p);
        opacity = lerp(0.55, 0.03, p);
        bg = `radial-gradient(circle, ${pal.edge}, ${pal.core}00 70%)`;
        blur = 6;
      }
      inner.style.width = `${size.toFixed(2)}px`;
      inner.style.height = `${size.toFixed(2)}px`;
      inner.style.opacity = opacity.toFixed(3);
      inner.style.background = bg;
      inner.style.boxShadow = shadow;
      inner.style.filter = blur ? `blur(${blur}px)` : "none";
      inner.style.transform = "translate(-50%, -50%)";
      if (sparkle && !reduce) {
        inner.classList.add("curtrail-twinkle");
        inner.style.animationDelay = `${((i % 7) * 0.12).toFixed(2)}s`;
      } else {
        inner.classList.remove("curtrail-twinkle");
      }
    }

    const poly = polyRef.current;
    if (poly) {
      if (enabled && style === "ribbon") {
        poly.setAttribute("stroke", pal.core);
        poly.setAttribute("stroke-width", "5");
        poly.style.opacity = "0.95";
        poly.style.filter = `drop-shadow(0 0 6px ${pal.core}66)`;
      } else {
        poly.style.opacity = "0";
      }
    }
  }, [enabled, style, palette, length, sparkle, reduce]);

  // Keep the head target inside the box across viewport changes.
  useEffect(() => {
    const onResize = () => {
      const el = containerRef.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const t = targetRef.current;
      targetRef.current = {
        x: Math.max(0, Math.min(r.width, t.x)),
        y: Math.max(0, Math.min(r.height, t.y)),
      };
    };
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!enabled) return;
    const el = containerRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    targetRef.current = { x: e.clientX - r.left, y: e.clientY - r.top };
    if (keyboardActive) setKeyboardActive(false);
  };

  const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (!enabled) return;
    const el = containerRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const step = e.shiftKey ? 48 : 20;
    const t = { ...targetRef.current };
    let handled = true;
    switch (e.key) {
      case "ArrowUp":
      case "w":
      case "W":
        t.y -= step;
        break;
      case "ArrowDown":
      case "s":
      case "S":
        t.y += step;
        break;
      case "ArrowLeft":
      case "a":
      case "A":
        t.x -= step;
        break;
      case "ArrowRight":
      case "d":
      case "D":
        t.x += step;
        break;
      default:
        handled = false;
    }
    if (!handled) return;
    e.preventDefault();
    t.x = Math.max(0, Math.min(r.width, t.x));
    t.y = Math.max(0, Math.min(r.height, t.y));
    targetRef.current = t;
    if (!keyboardActive) setKeyboardActive(true);
  };

  const activePalette =
    PALETTES.find((p) => p.key === palette) ?? PALETTES[0];

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes curtrail-twinkle {
          0%, 100% { transform: translate(-50%, -50%) scale(1); }
          50% { transform: translate(-50%, -50%) scale(1.45); }
        }
        @keyframes curtrail-pulse {
          0% { transform: scale(1); opacity: 0.55; }
          100% { transform: scale(1.9); opacity: 0; }
        }
        .curtrail-twinkle { animation: curtrail-twinkle 1.5s ease-in-out infinite; }
        .curtrail-pulse { animation: curtrail-pulse 2.2s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .curtrail-twinkle, .curtrail-pulse { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 uppercase dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <svg
              viewBox="0 0 24 24"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.8}
              aria-hidden="true"
            >
              <path
                d="M5 3l6 16 2.2-6.2L19.5 10.6 5 3z"
                strokeLinejoin="round"
                strokeLinecap="round"
              />
            </svg>
            Cursors · Pointer FX
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Cursor trail
          </h2>
          <p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
            Spring-eased segments chase your pointer across the canvas. Switch
            the render style, recolour the chain, stretch its length, and add a
            twinkle — every control is keyboard-drivable and the whole thing
            respects <span className="font-medium text-slate-800 dark:text-slate-200">reduced-motion</span>.
          </p>
        </header>

        <p id={hintId} className="sr-only">
          Interactive canvas. Move your pointer inside to draw a trailing cursor
          effect. Focus this area and press the arrow keys or the W, A, S and D
          keys to steer the trail with the keyboard. Hold Shift to move faster.
        </p>

        <div
          ref={containerRef}
          role="application"
          aria-label="Cursor trail canvas"
          aria-describedby={hintId}
          tabIndex={0}
          onPointerMove={handlePointerMove}
          onKeyDown={handleKeyDown}
          onBlur={() => setKeyboardActive(false)}
          className="relative h-[24rem] w-full cursor-crosshair overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 sm:h-[30rem] dark:border-slate-800 dark:bg-slate-950 dark:focus-visible:ring-offset-slate-900"
        >
          {/* grid backdrop */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 opacity-70"
            style={{
              backgroundImage:
                "linear-gradient(to right, rgba(100,116,139,0.10) 1px, transparent 1px), linear-gradient(to bottom, rgba(100,116,139,0.10) 1px, transparent 1px)",
              backgroundSize: "34px 34px",
            }}
          />
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
            style={{
              background:
                "radial-gradient(120% 120% at 50% 0%, transparent 55%, rgba(15,23,42,0.06) 100%)",
            }}
          />

          {/* idle hint */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 z-0 grid place-items-center"
          >
            <div className="flex flex-col items-center gap-3 text-center">
              <span className="relative grid h-14 w-14 place-items-center rounded-full border border-slate-300 text-slate-400 dark:border-slate-700 dark:text-slate-500">
                {!reduce && (
                  <span className="curtrail-pulse absolute inset-0 rounded-full border border-indigo-400/50" />
                )}
                <svg
                  viewBox="0 0 24 24"
                  className="h-6 w-6"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.6}
                  aria-hidden="true"
                >
                  <path
                    d="M5 3l6 16 2.2-6.2L19.5 10.6 5 3z"
                    strokeLinejoin="round"
                    strokeLinecap="round"
                  />
                </svg>
              </span>
              <span className="max-w-[16rem] text-xs font-medium text-slate-400 dark:text-slate-500">
                Move your pointer here — or focus this area and steer with the
                arrow keys
              </span>
            </div>
          </div>

          {/* ribbon overlay */}
          <svg
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 z-10 h-full w-full"
            style={{ overflow: "visible" }}
          >
            <polyline
              ref={polyRef}
              fill="none"
              strokeLinecap="round"
              strokeLinejoin="round"
              style={{ opacity: 0, transition: "opacity 200ms" }}
            />
          </svg>

          {/* trail segments */}
          <div className="pointer-events-none absolute inset-0 z-10">
            {Array.from({ length: MAX_SEGMENTS }).map((_, i) => (
              <span
                key={i}
                ref={(el) => {
                  dotRefs.current[i] = el;
                }}
                aria-hidden="true"
                className="absolute top-0 left-0"
                style={{ willChange: "transform" }}
              >
                <span
                  ref={(el) => {
                    innerRefs.current[i] = el;
                  }}
                  className="absolute top-0 left-0 block rounded-full"
                  style={{ opacity: 0 }}
                />
              </span>
            ))}
          </div>

          {/* keyboard status pill */}
          {keyboardActive && (
            <div
              aria-hidden="true"
              className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-full border border-slate-200 bg-white/90 px-3 py-1 text-xs font-medium text-slate-600 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
              Keyboard steering · arrow keys / WASD · Shift to sprint
            </div>
          )}
        </div>

        <p className="sr-only" role="status" aria-live="polite">
          {keyboardActive
            ? "Keyboard steering active. Use the arrow keys or W, A, S and D to move the trail."
            : ""}
        </p>

        {/* controls */}
        <div className="mt-6 grid gap-x-8 gap-y-7 rounded-2xl border border-slate-200 bg-white/70 p-5 backdrop-blur-sm sm:p-6 md:grid-cols-2 dark:border-slate-800 dark:bg-slate-900/50">
          {/* left column */}
          <div className="flex flex-col gap-6">
            <Switch
              checked={enabled}
              onChange={setEnabled}
              label="Trail active"
              hint="Turn the pointer effect on or off"
            />

            <div className="h-px bg-slate-200 dark:bg-slate-800" />

            <div>
              <div className="flex items-center justify-between">
                <label
                  htmlFor={lenId}
                  className="text-sm font-medium text-slate-800 dark:text-slate-100"
                >
                  Trail length
                </label>
                <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                  {length} segments
                </span>
              </div>
              <input
                id={lenId}
                type="range"
                min={6}
                max={MAX_SEGMENTS}
                step={1}
                value={length}
                onChange={(e) => setLength(Number(e.target.value))}
                aria-describedby={lenHintId}
                className="mt-3 h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-700 dark:accent-indigo-400 dark:focus-visible:ring-offset-slate-900"
              />
              <p
                id={lenHintId}
                className="mt-2 text-xs text-slate-500 dark:text-slate-400"
              >
                A longer chain leaves a heavier, slower-settling tail.
              </p>
            </div>

            <div className="h-px bg-slate-200 dark:bg-slate-800" />

            <Switch
              checked={sparkle}
              onChange={setSparkle}
              label="Twinkle"
              hint="Pulse each segment as it drifts"
            />
          </div>

          {/* right column */}
          <div className="flex flex-col gap-6">
            <fieldset>
              <legend className="text-sm font-medium text-slate-800 dark:text-slate-100">
                Render style
              </legend>
              <div className="mt-3 grid grid-cols-2 gap-2">
                {STYLES.map((s) => (
                  <label key={s.key} className="cursor-pointer">
                    <input
                      type="radio"
                      name={styleName}
                      value={s.key}
                      checked={style === s.key}
                      onChange={() => setStyle(s.key)}
                      className="peer sr-only"
                    />
                    <span className="flex h-full flex-col rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-slate-800 transition peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-200 dark:peer-focus-visible:ring-offset-slate-900">
                      <span className="text-sm font-semibold">{s.label}</span>
                      <span className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                        {s.hint}
                      </span>
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <fieldset>
              <legend className="text-sm font-medium text-slate-800 dark:text-slate-100">
                Chain colour
              </legend>
              <div className="mt-3 flex flex-wrap gap-3">
                {PALETTES.map((p) => (
                  <label key={p.key} className="cursor-pointer">
                    <input
                      type="radio"
                      name={palName}
                      value={p.key}
                      checked={palette === p.key}
                      onChange={() => setPalette(p.key)}
                      className="peer sr-only"
                    />
                    <span
                      aria-hidden="true"
                      style={{
                        background: `linear-gradient(135deg, ${p.edge}, ${p.core})`,
                      }}
                      className="block h-9 w-9 rounded-full ring-2 ring-transparent ring-offset-2 ring-offset-white transition peer-checked:ring-slate-900 peer-focus-visible:ring-indigo-500 dark:ring-offset-slate-900 dark:peer-checked:ring-white"
                    />
                    <span className="sr-only">{p.label}</span>
                  </label>
                ))}
              </div>
            </fieldset>
          </div>
        </div>

        <p className="mt-4 text-xs text-slate-500 dark:text-slate-400">
          Now drawing{" "}
          <span className="font-medium text-slate-700 dark:text-slate-300">
            {length} {style}
          </span>{" "}
          segments in{" "}
          <span
            className="font-medium"
            style={{ color: activePalette.core }}
          >
            {activePalette.label}
          </span>
          {sparkle ? " with twinkle" : ""}.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →