Web InnoventixFreeCode

Spotlight Background

Original · free

cursor spotlight background

byWeb InnoventixReact + Tailwind
bgxspotlightbackgrounds
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/bgx-spotlight.json
bgx-spotlight.tsx
"use client";

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

type SpotColor = {
  id: string;
  label: string;
  /** space-separated sRGB channels for the CSS rgb(r g b / a) form */
  rgb: string;
  swatch: string;
  checkedRing: string;
  accent: string;
};

const COLORS: SpotColor[] = [
  {
    id: "indigo",
    label: "Indigo",
    rgb: "129 140 248",
    swatch: "bg-indigo-400",
    checkedRing: "peer-checked:ring-indigo-400",
    accent: "accent-indigo-500 dark:accent-indigo-400",
  },
  {
    id: "violet",
    label: "Violet",
    rgb: "167 139 250",
    swatch: "bg-violet-400",
    checkedRing: "peer-checked:ring-violet-400",
    accent: "accent-violet-500 dark:accent-violet-400",
  },
  {
    id: "sky",
    label: "Sky",
    rgb: "56 189 248",
    swatch: "bg-sky-400",
    checkedRing: "peer-checked:ring-sky-400",
    accent: "accent-sky-500 dark:accent-sky-400",
  },
  {
    id: "emerald",
    label: "Emerald",
    rgb: "52 211 153",
    swatch: "bg-emerald-400",
    checkedRing: "peer-checked:ring-emerald-400",
    accent: "accent-emerald-500 dark:accent-emerald-400",
  },
  {
    id: "amber",
    label: "Amber",
    rgb: "251 191 36",
    swatch: "bg-amber-400",
    checkedRing: "peer-checked:ring-amber-400",
    accent: "accent-amber-500 dark:accent-amber-400",
  },
  {
    id: "rose",
    label: "Rose",
    rgb: "251 113 133",
    swatch: "bg-rose-400",
    checkedRing: "peer-checked:ring-rose-400",
    accent: "accent-rose-500 dark:accent-rose-400",
  },
];

const FALLBACK: SpotColor = COLORS[0] ?? {
  id: "indigo",
  label: "Indigo",
  rgb: "129 140 248",
  swatch: "bg-indigo-400",
  checkedRing: "peer-checked:ring-indigo-400",
  accent: "accent-indigo-500 dark:accent-indigo-400",
};

const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));

export default function BgxSpotlight() {
  const reduce = useReducedMotion();

  const [colorId, setColorId] = useState<string>("indigo");
  const [radius, setRadius] = useState<number>(340);
  const [intensity, setIntensity] = useState<number>(72);
  const [follow, setFollow] = useState<boolean>(true);
  const [copied, setCopied] = useState<boolean>(false);

  const active = COLORS.find((c) => c.id === colorId) ?? FALLBACK;

  const uid = useId();
  const radiusId = `${uid}-radius`;
  const intensityId = `${uid}-intensity`;
  const colorName = `${uid}-color`;
  const hintId = `${uid}-hint`;

  const stageRef = useRef<HTMLDivElement>(null);
  const xLabelRef = useRef<HTMLSpanElement>(null);
  const yLabelRef = useRef<HTMLSpanElement>(null);
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Pointer position stored as 0..1 fractions so it survives resize.
  const mx = useMotionValue(0.5);
  const my = useMotionValue(0.36);

  const springCfg = reduce
    ? { stiffness: 1600, damping: 90, mass: 0.6 }
    : { stiffness: 130, damping: 20, mass: 0.42 };
  const sx = useSpring(mx, springCfg);
  const sy = useSpring(my, springCfg);

  const px = useTransform(sx, (v) => `${(v * 100).toFixed(2)}%`);
  const py = useTransform(sy, (v) => `${(v * 100).toFixed(2)}%`);

  const glowAlpha = (0.14 + (intensity / 100) * 0.42).toFixed(3);
  const coreAlpha = (0.2 + (intensity / 100) * 0.5).toFixed(3);
  const coreRadius = Math.round(radius * 0.52);

  const glow = useMotionTemplate`radial-gradient(${radius}px circle at ${px} ${py}, rgb(${active.rgb} / ${glowAlpha}), transparent 68%)`;
  const core = useMotionTemplate`radial-gradient(${coreRadius}px circle at ${px} ${py}, rgb(${active.rgb} / ${coreAlpha}), transparent 66%)`;
  const reveal = useMotionTemplate`radial-gradient(${Math.round(
    radius * 0.72,
  )}px circle at ${px} ${py}, black 8%, transparent 72%)`;

  // Live readout without re-rendering the whole tree on every move.
  useMotionValueEvent(sx, "change", (v) => {
    if (xLabelRef.current) xLabelRef.current.textContent = String(Math.round(v * 100));
  });
  useMotionValueEvent(sy, "change", (v) => {
    if (yLabelRef.current) yLabelRef.current.textContent = String(Math.round(v * 100));
  });

  // Idle drift when the spotlight is not tracking the pointer.
  useEffect(() => {
    if (follow || reduce) return;
    let raf = 0;
    let t = Math.PI / 2;
    const loop = () => {
      t += 0.006;
      mx.set(clamp01(0.5 + 0.3 * Math.sin(t)));
      my.set(clamp01(0.4 + 0.22 * Math.cos(t * 1.25)));
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [follow, reduce, mx, my]);

  useEffect(() => {
    return () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
    };
  }, []);

  const moveToPointer = useCallback(
    (event: ReactPointerEvent<HTMLDivElement>) => {
      if (!follow) return;
      const el = stageRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      mx.set(clamp01((event.clientX - rect.left) / rect.width));
      my.set(clamp01((event.clientY - rect.top) / rect.height));
    },
    [follow, mx, my],
  );

  const onKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLDivElement>) => {
      const step = event.shiftKey ? 0.12 : 0.045;
      let handled = true;
      switch (event.key) {
        case "ArrowLeft":
          mx.set(clamp01(mx.get() - step));
          break;
        case "ArrowRight":
          mx.set(clamp01(mx.get() + step));
          break;
        case "ArrowUp":
          my.set(clamp01(my.get() - step));
          break;
        case "ArrowDown":
          my.set(clamp01(my.get() + step));
          break;
        case "Home":
          mx.set(0.5);
          my.set(0.36);
          break;
        default:
          handled = false;
      }
      if (handled) event.preventDefault();
    },
    [mx, my],
  );

  const onCopy = useCallback(async () => {
    const snippet = `import BgxSpotlight from "@/registry/bgx-spotlight/bgx-spotlight";`;
    try {
      await navigator.clipboard.writeText(snippet);
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 1900);
    } catch {
      setCopied(false);
    }
  }, []);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-neutral-950 dark:text-white">
      <style>{`
        @keyframes bgxsl-breathe {
          0%, 100% { opacity: .5; transform: scale(1); }
          50% { opacity: .8; transform: scale(1.06); }
        }
        @keyframes bgxsl-sweep {
          0% { transform: translateX(-140%); }
          100% { transform: translateX(240%); }
        }
        @media (prefers-reduced-motion: reduce) {
          .bgxsl-anim { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto grid max-w-6xl items-stretch gap-5 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
        {/* ─── Interactive stage ─────────────────────────────── */}
        <div
          ref={stageRef}
          role="application"
          aria-label="Cursor spotlight canvas. Move the pointer, or focus this area and use the arrow keys to steer the light."
          aria-describedby={hintId}
          tabIndex={0}
          onPointerMove={moveToPointer}
          onPointerDown={moveToPointer}
          onKeyDown={onKeyDown}
          className="group relative flex min-h-[460px] flex-col justify-between overflow-hidden rounded-3xl border border-slate-200 bg-white p-7 shadow-sm outline-none sm:min-h-[560px] sm:p-10 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-neutral-900 dark:focus-visible:ring-white dark:focus-visible:ring-offset-neutral-950"
        >
          {/* ruled grid texture */}
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 opacity-[0.5] dark:opacity-[0.28]"
            style={{
              backgroundImage:
                "linear-gradient(to right, rgb(100 116 139 / 0.16) 1px, transparent 1px), linear-gradient(to bottom, rgb(100 116 139 / 0.16) 1px, transparent 1px)",
              backgroundSize: "46px 46px",
            }}
          />

          {/* grid revealed brighter under the spotlight */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
            style={{
              backgroundImage:
                "linear-gradient(to right, rgb(100 116 139 / 0.42) 1px, transparent 1px), linear-gradient(to bottom, rgb(100 116 139 / 0.42) 1px, transparent 1px)",
              backgroundSize: "46px 46px",
              maskImage: reduce ? undefined : reveal,
              WebkitMaskImage: reduce ? undefined : reveal,
              opacity: reduce ? 0 : 1,
            }}
          />

          {/* soft outer glow that trails the cursor */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
            style={{ backgroundImage: glow }}
          />
          {/* crisp inner core */}
          <motion.div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0"
            style={{ backgroundImage: core }}
          />

          {/* idle corner ember, always alive */}
          <div
            aria-hidden="true"
            className="bgxsl-anim pointer-events-none absolute -right-16 -top-16 h-56 w-56 rounded-full bg-slate-400/20 blur-3xl dark:bg-white/10"
            style={{ animation: reduce ? undefined : "bgxsl-breathe 7s ease-in-out infinite" }}
          />

          {/* content */}
          <div className="relative">
            <span className="relative inline-flex items-center gap-2 overflow-hidden rounded-full border border-slate-200 bg-white/80 px-3.5 py-1.5 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
              <span className={`h-2 w-2 rounded-full ${active.swatch}`} />
              Interactive background
              {!reduce && (
                <span
                  aria-hidden="true"
                  className="bgxsl-anim absolute inset-y-0 -left-full w-1/2 -skew-x-12 bg-gradient-to-r from-transparent via-white/50 to-transparent dark:via-white/20"
                  style={{ animation: "bgxsl-sweep 4.5s ease-in-out infinite" }}
                />
              )}
            </span>
          </div>

          <div className="relative mt-auto max-w-xl">
            <h2 className="text-3xl font-semibold tracking-tight sm:text-5xl">
              Point anywhere. <br className="hidden sm:block" />
              The light chases you.
            </h2>
            <p className="mt-4 text-sm leading-relaxed text-slate-600 sm:text-base dark:text-slate-400">
              A live radial glow renders wherever your cursor lands and eases in on a
              spring. Steer it with the pointer, or focus this panel and nudge it with
              the arrow keys. Every value on the right is wired to real controls.
            </p>

            <div className="mt-7 flex flex-wrap items-center gap-3">
              <button
                type="button"
                onClick={onCopy}
                className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white shadow-lg transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-white dark:focus-visible:ring-offset-neutral-900"
              >
                {copied ? (
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                    <path d="M20 6 9 17l-5-5" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                    <rect x="9" y="9" width="11" height="11" rx="2" />
                    <path d="M5 15V5a2 2 0 0 1 2-2h10" />
                  </svg>
                )}
                {copied ? "Copied import" : "Copy import"}
              </button>
              <a
                href="#bgxsl-controls"
                className="inline-flex items-center gap-2 rounded-full border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/15 dark:text-slate-200 dark:hover:border-white/30 dark:hover:bg-white/5 dark:focus-visible:ring-white dark:focus-visible:ring-offset-neutral-900"
              >
                Tune the glow
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                  <path d="M5 12h14M13 6l6 6-6 6" />
                </svg>
              </a>
              <span aria-live="polite" className="sr-only">
                {copied ? "Import statement copied to clipboard" : ""}
              </span>
            </div>

            <p id={hintId} className="mt-6 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-500 dark:text-slate-500">
              <span className="inline-flex items-center gap-1.5 font-mono">
                <span className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 dark:border-white/10 dark:bg-white/5">x</span>
                <span ref={xLabelRef} className="tabular-nums">50</span>
                <span className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 dark:border-white/10 dark:bg-white/5">y</span>
                <span ref={yLabelRef} className="tabular-nums">36</span>
              </span>
              <span>Arrow keys steer · Shift for bigger steps · Home recentres</span>
            </p>
          </div>
        </div>

        {/* ─── Control panel ─────────────────────────────────── */}
        <div
          id="bgxsl-controls"
          className="flex flex-col gap-7 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-7 dark:border-white/10 dark:bg-neutral-900"
        >
          <div>
            <h3 className="text-lg font-semibold tracking-tight">Spotlight studio</h3>
            <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
              Live-tune the beam. Changes render on the canvas instantly.
            </p>
          </div>

          {/* colour */}
          <fieldset>
            <legend className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Beam colour
            </legend>
            <div role="radiogroup" aria-label="Spotlight colour" className="flex flex-wrap gap-3">
              {COLORS.map((c) => (
                <label key={c.id} className="relative cursor-pointer" title={c.label}>
                  <input
                    type="radio"
                    name={colorName}
                    value={c.id}
                    checked={colorId === c.id}
                    onChange={() => setColorId(c.id)}
                    className="peer sr-only"
                  />
                  <span
                    aria-hidden="true"
                    className={`block h-9 w-9 rounded-full ring-2 ring-transparent ring-offset-2 ring-offset-white transition dark:ring-offset-neutral-900 ${c.swatch} ${c.checkedRing} peer-focus-visible:ring-slate-900 dark:peer-focus-visible:ring-white`}
                  />
                  <span className="sr-only">{c.label}</span>
                </label>
              ))}
            </div>
          </fieldset>

          {/* radius */}
          <div>
            <div className="mb-2 flex items-baseline justify-between">
              <label htmlFor={radiusId} className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Beam radius
              </label>
              <span className="font-mono text-sm tabular-nums text-slate-700 dark:text-slate-200">{radius}px</span>
            </div>
            <input
              id={radiusId}
              type="range"
              min={160}
              max={540}
              step={10}
              value={radius}
              onChange={(e) => setRadius(Number(e.target.value))}
              className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white/10 dark:focus-visible:ring-offset-neutral-900 focus-visible:ring-slate-900 dark:focus-visible:ring-white ${active.accent}`}
            />
          </div>

          {/* intensity */}
          <div>
            <div className="mb-2 flex items-baseline justify-between">
              <label htmlFor={intensityId} className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Intensity
              </label>
              <span className="font-mono text-sm tabular-nums text-slate-700 dark:text-slate-200">{intensity}%</span>
            </div>
            <input
              id={intensityId}
              type="range"
              min={20}
              max={100}
              step={1}
              value={intensity}
              onChange={(e) => setIntensity(Number(e.target.value))}
              className={`h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white/10 dark:focus-visible:ring-offset-neutral-900 focus-visible:ring-slate-900 dark:focus-visible:ring-white ${active.accent}`}
            />
          </div>

          {/* follow switch */}
          <div className="flex items-center justify-between gap-4 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3.5 dark:border-white/10 dark:bg-white/5">
            <span className="flex flex-col">
              <span className="text-sm font-medium text-slate-800 dark:text-slate-100">Follow cursor</span>
              <span className="text-xs text-slate-500 dark:text-slate-400">
                {follow ? "Tracking your pointer" : "Drifting on autopilot"}
              </span>
            </span>
            <button
              type="button"
              role="switch"
              aria-checked={follow}
              aria-label="Follow cursor"
              onClick={() => setFollow((v) => !v)}
              className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-neutral-900 focus-visible:ring-slate-900 dark:focus-visible:ring-white ${
                follow ? "bg-emerald-500" : "bg-slate-300 dark:bg-white/20"
              }`}
            >
              <span
                aria-hidden="true"
                className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
                  follow ? "translate-x-5" : "translate-x-0.5"
                }`}
              />
            </button>
          </div>

          <p className="mt-auto text-xs leading-relaxed text-slate-500 dark:text-slate-500">
            Honours <span className="font-medium text-slate-600 dark:text-slate-300">prefers-reduced-motion</span>: the spring settles instantly and idle
            drift pauses. Fully keyboard-operable, works in light and dark.
          </p>
        </div>
      </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 →