Web InnoventixFreeCode

Dot Follow Cursor

Original · free

dot cursor follower

byWeb InnoventixReact + Tailwind
curdotfollowcursors
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-dot-follow.json
cur-dot-follow.tsx
"use client";

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

type PresetKey = "snappy" | "smooth" | "lazy";

const PRESETS: Record<
  PresetKey,
  { label: string; stiffness: number; damping: number; mass: number }
> = {
  snappy: { label: "Snappy", stiffness: 700, damping: 42, mass: 0.55 },
  smooth: { label: "Smooth", stiffness: 320, damping: 32, mass: 0.9 },
  lazy: { label: "Lazy", stiffness: 90, damping: 18, mass: 1.35 },
};

const PRESET_KEYS: PresetKey[] = ["snappy", "smooth", "lazy"];

function Toggle({
  id,
  checked,
  onChange,
  label,
}: {
  id: string;
  checked: boolean;
  onChange: (next: boolean) => void;
  label: string;
}) {
  return (
    <button
      type="button"
      id={id}
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className="group inline-flex items-center gap-2.5 rounded-lg px-1 py-1 text-sm font-medium text-slate-700 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-200 dark:focus-visible:ring-offset-slate-900"
    >
      <span
        aria-hidden="true"
        className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 ${
          checked
            ? "bg-indigo-500 dark:bg-indigo-400"
            : "bg-slate-300 dark:bg-slate-700"
        }`}
      >
        <span
          className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 dark:bg-slate-950 ${
            checked ? "translate-x-4" : "translate-x-0.5"
          }`}
        />
      </span>
      <span>{label}</span>
    </button>
  );
}

export default function CursorDotFollow() {
  const uid = useId();
  const stageRef = useRef<HTMLDivElement | null>(null);
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced === true;

  const [enabled, setEnabled] = useState(true);
  const [preset, setPreset] = useState<PresetKey>("smooth");
  const [halo, setHalo] = useState(true);
  const [hideNative, setHideNative] = useState(true);
  const [active, setActive] = useState(false);
  const [pressed, setPressed] = useState(false);
  const [pos, setPos] = useState({ x: 0, y: 0 });

  const cfg = PRESETS[preset];

  const x = useMotionValue(0);
  const y = useMotionValue(0);

  const dotSpring = reduce
    ? { stiffness: 1200, damping: 80, mass: 0.2 }
    : { stiffness: cfg.stiffness, damping: cfg.damping, mass: cfg.mass };
  const haloSpring = reduce
    ? { stiffness: 1200, damping: 80, mass: 0.2 }
    : {
        stiffness: cfg.stiffness * 0.45,
        damping: cfg.damping * 1.05,
        mass: cfg.mass * 1.7,
      };

  const dotX = useSpring(x, dotSpring);
  const dotY = useSpring(y, dotSpring);
  const haloX = useSpring(x, haloSpring);
  const haloY = useSpring(y, haloSpring);

  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const cx = rect.width / 2;
    const cy = rect.height / 2;
    x.set(cx);
    y.set(cy);
    dotX.jump(cx);
    dotY.jump(cy);
    haloX.jump(cx);
    haloY.jump(cy);
    setPos({ x: Math.round(cx), y: Math.round(cy) });
  }, [x, y, dotX, dotY, haloX, haloY]);

  const commit = (nx: number, ny: number) => {
    x.set(nx);
    y.set(ny);
    setPos({ x: Math.round(nx), y: Math.round(ny) });
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!enabled) return;
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    commit(e.clientX - rect.left, e.clientY - rect.top);
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (!enabled) return;
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const step = e.shiftKey ? 44 : 14;
    let nx = x.get();
    let ny = y.get();
    switch (e.key) {
      case "ArrowUp":
        ny -= step;
        break;
      case "ArrowDown":
        ny += step;
        break;
      case "ArrowLeft":
        nx -= step;
        break;
      case "ArrowRight":
        nx += step;
        break;
      default:
        return;
    }
    e.preventDefault();
    setActive(true);
    commit(
      Math.max(0, Math.min(rect.width, nx)),
      Math.max(0, Math.min(rect.height, ny)),
    );
  };

  const onPresetKey = (e: ReactKeyboardEvent<HTMLButtonElement>, key: PresetKey) => {
    const idx = PRESET_KEYS.indexOf(key);
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      next = (idx + 1) % PRESET_KEYS.length;
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      next = (idx - 1 + PRESET_KEYS.length) % PRESET_KEYS.length;
    } else {
      return;
    }
    e.preventDefault();
    setPreset(PRESET_KEYS[next]);
    const group = e.currentTarget.parentElement;
    const radios = group?.querySelectorAll<HTMLButtonElement>('[role="radio"]');
    radios?.[next]?.focus();
  };

  const show = enabled && active;
  const showHint = !enabled || !active;
  const hideCursor = enabled && hideNative;

  const springTransition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 520, damping: 30 };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-16 sm:py-20 lg:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes curdotfollow-breathe {
          0%, 100% { transform: scale(1); opacity: 0.85; }
          50% { transform: scale(1.14); opacity: 1; }
        }
        .curdotfollow-breathe { animation: curdotfollow-breathe 2.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .curdotfollow-breathe { animation: none; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl px-6">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Cursors / Interaction
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
            A dot that follows your pointer
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Glide your cursor across the stage and a spring-weighted dot chases
            it with real physics. No mouse? Focus the stage and steer with the
            arrow keys — hold Shift to leap further.
          </p>
        </header>

        <div
          ref={stageRef}
          role="group"
          aria-label="Interactive pointer stage. Move your mouse over this area, or focus it and use the arrow keys, to steer the follower dot."
          tabIndex={0}
          onPointerMove={onPointerMove}
          onPointerEnter={() => setActive(true)}
          onPointerLeave={() => {
            setActive(false);
            setPressed(false);
          }}
          onPointerDown={() => setPressed(true)}
          onPointerUp={() => setPressed(false)}
          onFocus={() => setActive(true)}
          onBlur={() => setActive(false)}
          onKeyDown={onKeyDown}
          className={`relative mt-10 h-[360px] w-full select-none overflow-hidden rounded-3xl border border-slate-200 bg-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${
            hideCursor ? "cursor-none" : "cursor-crosshair"
          }`}
        >
          <div
            aria-hidden="true"
            className="absolute inset-0 bg-[radial-gradient(circle,#e2e8f0_1px,transparent_1px)] opacity-70 [background-size:22px_22px] dark:bg-[radial-gradient(circle,#334155_1px,transparent_1px)]"
          />

          {/* Idle / paused hint */}
          <div
            aria-hidden="true"
            className={`pointer-events-none absolute inset-0 grid place-items-center px-6 transition-opacity duration-300 ${
              showHint ? "opacity-100" : "opacity-0"
            }`}
          >
            <div className="flex flex-col items-center gap-3 text-center">
              <span className="curdotfollow-breathe grid h-16 w-16 place-items-center rounded-full border border-indigo-300/70 text-indigo-500 dark:border-indigo-400/40 dark:text-indigo-300">
                <svg
                  viewBox="0 0 24 24"
                  className="h-6 w-6"
                  fill="currentColor"
                  aria-hidden="true"
                >
                  <path d="M5.5 3.2a1 1 0 0 1 1.05-.16l12.2 5.2a1 1 0 0 1-.13 1.88l-4.9 1.35a1 1 0 0 0-.62.5l-2.4 4.55a1 1 0 0 1-1.87-.2L5.1 4.3a1 1 0 0 1 .4-1.1Z" />
                </svg>
              </span>
              <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                {enabled ? (
                  <>
                    Move your pointer here — or press{" "}
                    <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
                      Tab
                    </kbd>{" "}
                    then the arrow keys
                  </>
                ) : (
                  "Follower is paused — flip it back on below"
                )}
              </span>
            </div>
          </div>

          {/* Halo ring */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 z-10"
            style={{ x: haloX, y: haloY }}
          >
            <motion.div
              animate={{ scale: pressed ? 1.35 : 1, opacity: show && halo ? 1 : 0 }}
              transition={springTransition}
              className="-ml-7 -mt-7 h-14 w-14 rounded-full border border-indigo-400/60 bg-indigo-400/10 dark:border-indigo-300/40 dark:bg-indigo-300/5"
            />
          </motion.div>

          {/* Follower dot */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 z-20"
            style={{ x: dotX, y: dotY }}
          >
            <motion.div
              animate={{ scale: pressed ? 0.62 : 1, opacity: show ? 1 : 0 }}
              transition={springTransition}
              className="-ml-2 -mt-2 h-4 w-4 rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 shadow-[0_0_22px_rgba(99,102,241,0.55)]"
            />
          </motion.div>

          {/* Precise pointer marker */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute left-0 top-0 z-30"
            style={{ x, y }}
          >
            <div
              className={`-ml-1 -mt-1 h-2 w-2 rounded-full bg-slate-900 transition-opacity duration-200 dark:bg-white ${
                show ? "opacity-100" : "opacity-0"
              }`}
            />
          </motion.div>

          {/* Live readout */}
          <div className="pointer-events-none absolute bottom-3 left-3 z-40 rounded-lg bg-slate-900/5 px-2.5 py-1 font-mono text-[11px] text-slate-500 backdrop-blur-sm dark:bg-white/5 dark:text-slate-400">
            x {pos.x} · y {pos.y} · {show ? "chasing" : "idle"}
          </div>
        </div>

        {/* Controls */}
        <div className="mt-6 flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-5 sm:flex-row sm:flex-wrap sm:items-center dark:border-slate-800 dark:bg-slate-900">
          <Toggle
            id={`${uid}-enabled`}
            checked={enabled}
            onChange={setEnabled}
            label="Follower"
          />

          <div
            role="radiogroup"
            aria-label="Spring feel"
            className="inline-flex rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800"
          >
            {PRESET_KEYS.map((key) => {
              const selected = preset === key;
              return (
                <button
                  key={key}
                  type="button"
                  role="radio"
                  aria-checked={selected}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setPreset(key)}
                  onKeyDown={(e) => onPresetKey(e, key)}
                  className={`rounded-lg px-3 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
                    selected
                      ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-slate-50"
                      : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
                  }`}
                >
                  {PRESETS[key].label}
                </button>
              );
            })}
          </div>

          <Toggle
            id={`${uid}-halo`}
            checked={halo}
            onChange={setHalo}
            label="Halo ring"
          />
          <Toggle
            id={`${uid}-native`}
            checked={hideNative}
            onChange={setHideNative}
            label="Hide native cursor"
          />

          <p className="font-mono text-xs text-slate-500 sm:ml-auto dark:text-slate-400">
            {reduce
              ? "reduced-motion · instant"
              : `stiffness ${cfg.stiffness} · damping ${cfg.damping}`}
          </p>
        </div>

        <p className="mt-4 text-xs text-slate-500 dark:text-slate-400">
          Arrow keys steer · Shift leaps further · click and hold to compress the
          dot.
        </p>

        <span className="sr-only" aria-live="polite">
          {enabled ? `Follower on, ${cfg.label} spring` : "Follower off"}
        </span>
      </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 →