Web InnoventixFreeCode

Spotlight Cursor

Original · free

cursor spotlight mask

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

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Star = {
  name: string;
  constellation: string;
  magnitude: string;
  distance: string;
  note: string;
};

const STARS: Star[] = [
  {
    name: "Sirius",
    constellation: "Canis Major",
    magnitude: "mag −1.46",
    distance: "8.6 light-years",
    note: "The brightest star in the night sky — a hot blue-white sun orbited by the faint white dwarf Sirius B.",
  },
  {
    name: "Betelgeuse",
    constellation: "Orion",
    magnitude: "mag 0.0–1.3",
    distance: "≈ 548 light-years",
    note: "A red supergiant so vast it would swallow the orbit of Jupiter. It dimmed dramatically through 2019 and 2020.",
  },
  {
    name: "Rigel",
    constellation: "Orion",
    magnitude: "mag 0.13",
    distance: "≈ 860 light-years",
    note: "A blue supergiant roughly 120,000 times more luminous than the Sun, marking the hunter's left foot.",
  },
  {
    name: "Aldebaran",
    constellation: "Taurus",
    magnitude: "mag 0.86",
    distance: "65 light-years",
    note: "The orange eye of the bull. It only appears to sit among the Hyades cluster — it lies far in the foreground.",
  },
  {
    name: "Capella",
    constellation: "Auriga",
    magnitude: "mag 0.08",
    distance: "43 light-years",
    note: "Actually four stars in two pairs. The brightest golden point riding high overhead on winter evenings.",
  },
  {
    name: "Procyon",
    constellation: "Canis Minor",
    magnitude: "mag 0.34",
    distance: "11.5 light-years",
    note: "The 'little dog' star. With Sirius and Betelgeuse it forms the vast Winter Triangle.",
  },
];

function mulberry32(seed: number) {
  return function next(): number {
    seed |= 0;
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));

const GRID_CLS = "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";

function StarCard({ star, dim }: { star: Star; dim: boolean }) {
  if (dim) {
    return (
      <div className="rounded-2xl border border-white/[0.06] bg-white/[0.015] p-4 sm:p-5">
        <div className="flex items-baseline justify-between gap-3">
          <h3 className="text-lg font-semibold text-slate-600">{star.name}</h3>
          <span className="rounded-full bg-white/[0.04] px-2 py-0.5 text-xs font-medium text-slate-700 ring-1 ring-white/[0.05]">
            {star.magnitude}
          </span>
        </div>
        <p className="mt-1 text-xs font-medium uppercase tracking-wider text-slate-700">
          {star.constellation}
        </p>
        <p className="mt-2 text-sm text-slate-700">{star.distance}</p>
        <p className="mt-3 text-sm leading-relaxed text-slate-700">{star.note}</p>
      </div>
    );
  }

  return (
    <div className="rounded-2xl border border-indigo-400/25 bg-gradient-to-br from-white/[0.07] to-white/[0.02] p-4 shadow-lg shadow-indigo-950/40 backdrop-blur-sm sm:p-5">
      <div className="flex items-baseline justify-between gap-3">
        <h3 className="text-lg font-semibold text-white">{star.name}</h3>
        <span className="rounded-full bg-amber-400/15 px-2 py-0.5 text-xs font-medium text-amber-300 ring-1 ring-amber-400/30">
          {star.magnitude}
        </span>
      </div>
      <p className="mt-1 text-xs font-medium uppercase tracking-wider text-indigo-300">
        {star.constellation}
      </p>
      <p className="mt-2 text-sm text-sky-300">{star.distance}</p>
      <p className="mt-3 text-sm leading-relaxed text-slate-300">{star.note}</p>
    </div>
  );
}

function Switch({
  checked,
  onChange,
  label,
  disabled = false,
}: {
  checked: boolean;
  onChange: (v: boolean) => void;
  label: string;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      disabled={disabled}
      onClick={() => onChange(!checked)}
      className="group inline-flex items-center gap-2.5 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-offset-neutral-950"
    >
      <span
        className={`relative h-6 w-11 rounded-full transition-colors duration-200 ${
          checked ? "bg-indigo-500" : "bg-slate-300 dark:bg-white/15"
        }`}
      >
        <span
          className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
            checked ? "translate-x-5" : "translate-x-0"
          }`}
        />
      </span>
      <span className="text-sm font-medium text-slate-700 dark:text-slate-200">{label}</span>
    </button>
  );
}

export default function CurSpotlight() {
  const reduced = useReducedMotion();
  const stageRef = useRef<HTMLDivElement>(null);
  const posRef = useRef<{ x: number; y: number }>({ x: 0.5, y: 0.42 });

  const [enabled, setEnabled] = useState(true);
  const [auto, setAuto] = useState(false);
  const [radius, setRadius] = useState(170);

  const dots = useMemo(() => {
    const rnd = mulberry32(20260716);
    return Array.from({ length: 48 }, () => ({
      x: rnd() * 100,
      y: rnd() * 100,
      s: 1 + rnd() * 2.2,
      d: rnd() * 4,
    }));
  }, []);

  const applyPos = useCallback(() => {
    const el = stageRef.current;
    if (!el) return;
    el.style.setProperty("--spot-x", `${posRef.current.x * 100}%`);
    el.style.setProperty("--spot-y", `${posRef.current.y * 100}%`);
  }, []);

  useEffect(() => {
    applyPos();
  }, [applyPos]);

  useEffect(() => {
    stageRef.current?.style.setProperty("--spot-r", `${radius}px`);
  }, [radius]);

  useEffect(() => {
    if (!auto || !enabled || reduced) return;
    let raf = 0;
    let t0: number | null = null;
    const loop = (t: number) => {
      if (t0 === null) t0 = t;
      const e = (t - t0) / 1000;
      posRef.current = {
        x: clamp(0.5 + Math.cos(e * 0.85) * 0.33, 0, 1),
        y: clamp(0.46 + Math.sin(e * 0.85 * 1.35) * 0.3, 0, 1),
      };
      applyPos();
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [auto, enabled, reduced, applyPos]);

  const moveFromClient = (clientX: number, clientY: number) => {
    const el = stageRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    posRef.current = {
      x: clamp((clientX - r.left) / r.width, 0, 1),
      y: clamp((clientY - r.top) / r.height, 0, 1),
    };
    applyPos();
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-slate-100 px-4 py-16 sm:px-6 sm:py-24 dark:from-neutral-950 dark:to-black">
      <style>{`
@keyframes curspot-twinkle { 0%, 100% { opacity: .25 } 50% { opacity: .85 } }
@keyframes curspot-halo { 0%, 100% { opacity: .35 } 50% { opacity: .8 } }
.curspot-twinkle { animation: curspot-twinkle 3.6s ease-in-out infinite; }
.curspot-halo { animation: curspot-halo 2.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .curspot-twinkle, .curspot-halo { animation: none !important; }
}
`}</style>

      <motion.div
        initial={reduced ? false : { opacity: 0, y: 14 }}
        animate={reduced ? false : { opacity: 1, y: 0 }}
        transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
        className="mx-auto w-full max-w-6xl"
      >
        <div className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-500 dark:text-indigo-400">
            Interactive star chart
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Sweep the spotlight across the dark
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Everything here starts in shadow. Move your cursor &mdash; or steer with the keyboard
            &mdash; and a soft beam reveals each star as it passes. Six real fixtures of the northern
            winter sky, waiting to be found.
          </p>
        </div>

        <div className="mt-8 flex flex-wrap items-center gap-x-7 gap-y-4 rounded-2xl border border-slate-200 bg-white/70 p-4 backdrop-blur sm:px-6 dark:border-white/10 dark:bg-white/5">
          <Switch label="Spotlight" checked={enabled} onChange={setEnabled} />
          <Switch
            label="Auto-orbit"
            checked={auto}
            onChange={setAuto}
            disabled={!enabled || !!reduced}
          />
          <div className="flex items-center gap-3">
            <label
              htmlFor="curspot-radius"
              className="text-sm font-medium text-slate-700 dark:text-slate-200"
            >
              Beam size
            </label>
            <input
              id="curspot-radius"
              type="range"
              min={90}
              max={320}
              step={5}
              value={radius}
              disabled={!enabled}
              onChange={(e) => setRadius(Number(e.target.value))}
              aria-valuetext={`${radius} pixels`}
              className="h-1.5 w-36 cursor-pointer appearance-none rounded-full bg-slate-300 accent-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white/15 dark:focus-visible:ring-offset-neutral-950"
            />
            <span className="w-12 tabular-nums text-sm text-slate-500 dark:text-slate-400">
              {radius}px
            </span>
          </div>
        </div>

        <div
          ref={stageRef}
          tabIndex={0}
          role="group"
          aria-label="Star chart spotlight. Use arrow keys to move the beam of light."
          aria-describedby="curspot-hint"
          onPointerMove={(e) => {
            if (!enabled) return;
            if (auto) setAuto(false);
            moveFromClient(e.clientX, e.clientY);
          }}
          onKeyDown={(e) => {
            if (!enabled) return;
            const step = 0.05;
            const p = { ...posRef.current };
            let handled = true;
            switch (e.key) {
              case "ArrowUp":
              case "w":
              case "W":
                p.y -= step;
                break;
              case "ArrowDown":
              case "s":
              case "S":
                p.y += step;
                break;
              case "ArrowLeft":
              case "a":
              case "A":
                p.x -= step;
                break;
              case "ArrowRight":
              case "d":
              case "D":
                p.x += step;
                break;
              case "Home":
                p.x = 0.5;
                p.y = 0.5;
                break;
              case "+":
              case "=":
                setRadius((r) => clamp(r + 15, 90, 320));
                break;
              case "-":
              case "_":
                setRadius((r) => clamp(r - 15, 90, 320));
                break;
              default:
                handled = false;
            }
            if (!handled) return;
            e.preventDefault();
            if (auto) setAuto(false);
            posRef.current = { x: clamp(p.x, 0, 1), y: clamp(p.y, 0, 1) };
            applyPos();
          }}
          className={`relative isolate mt-8 min-h-[460px] overflow-hidden rounded-3xl border border-white/10 bg-neutral-950 shadow-2xl select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 sm:min-h-[540px] dark:focus-visible:ring-offset-black ${
            enabled ? "cursor-crosshair" : "cursor-default"
          }`}
        >
          {/* faint background starfield */}
          <div aria-hidden className="pointer-events-none absolute inset-0 z-0">
            {dots.map((d, i) => (
              <span
                key={i}
                className="curspot-twinkle absolute rounded-full bg-white"
                style={{
                  left: `${d.x}%`,
                  top: `${d.y}%`,
                  width: d.s,
                  height: d.s,
                  opacity: 0.5,
                  animationDelay: `${d.d}s`,
                }}
              />
            ))}
          </div>

          {/* edge vignette over the unlit layer */}
          <div
            aria-hidden
            className="pointer-events-none absolute inset-0 z-[5]"
            style={{
              background:
                "radial-gradient(circle at 50% 42%, transparent 38%, rgba(0,0,0,0.55) 100%)",
            }}
          />

          {/* unlit (dim) content — hidden from assistive tech to avoid duplication */}
          <div aria-hidden className="relative z-10 p-5 sm:p-8">
            <div className={GRID_CLS}>
              {STARS.map((s) => (
                <StarCard key={s.name} star={s} dim />
              ))}
            </div>
          </div>

          {/* lit (vivid) content, masked to the beam — this layer carries the a11y text */}
          <div
            className="absolute inset-0 z-20 p-5 sm:p-8"
            style={
              enabled
                ? {
                    WebkitMaskImage:
                      "radial-gradient(circle var(--spot-r) at var(--spot-x) var(--spot-y), #000 0%, #000 58%, rgba(0,0,0,0.35) 78%, transparent 100%)",
                    maskImage:
                      "radial-gradient(circle var(--spot-r) at var(--spot-x) var(--spot-y), #000 0%, #000 58%, rgba(0,0,0,0.35) 78%, transparent 100%)",
                  }
                : undefined
            }
          >
            <div className={GRID_CLS}>
              {STARS.map((s) => (
                <StarCard key={s.name} star={s} dim={false} />
              ))}
            </div>
          </div>

          {/* additive glow + beam ring */}
          {enabled ? (
            <>
              <div
                aria-hidden
                className="pointer-events-none absolute inset-0 z-30 mix-blend-screen"
                style={{
                  background:
                    "radial-gradient(circle var(--spot-r) at var(--spot-x) var(--spot-y), rgba(129,140,248,0.30) 0%, rgba(129,140,248,0.10) 45%, transparent 70%)",
                }}
              />
              <div
                aria-hidden
                className="curspot-halo pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded-full border border-indigo-300/40"
                style={{
                  left: "var(--spot-x)",
                  top: "var(--spot-y)",
                  width: "calc(var(--spot-r) * 2)",
                  height: "calc(var(--spot-r) * 2)",
                }}
              />
            </>
          ) : null}
        </div>

        <p id="curspot-hint" className="mt-3 text-xs leading-relaxed text-slate-500 sm:text-sm dark:text-slate-400">
          Focus the panel and steer the beam with the arrow keys or W A S D. Press{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1 font-sans text-[0.7em] text-slate-600 dark:border-white/15 dark:bg-white/10 dark:text-slate-300">
            +
          </kbd>{" "}
          /{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1 font-sans text-[0.7em] text-slate-600 dark:border-white/15 dark:bg-white/10 dark:text-slate-300">
            &minus;
          </kbd>{" "}
          to resize it, or switch off Spotlight to bring the house lights up.
        </p>
      </motion.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 →