Spotlight Cursor
Cursors & Pointer

Spotlight Cursor

The pointer carries a feathered radial spotlight that reveals content in full through a dimmed, desaturated overlay, gliding on a soft spring.

Install

npx shadcn@latest add @paragon/spotlight-cursor

spotlight-cursor.tsx

"use client";

import * as React from "react";
import {
  animate,
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";

/**
 * SpotlightCursor — the pointer carries a radial spotlight that reveals the
 * content in full through a dimmed, desaturated overlay. A single dimming layer
 * covers the surface; a feathered radial mask cuts a hole where the beam is, so
 * the underlying content shows through at full brightness. The hole glides on a
 * soft spring, teleports to wherever the pointer enters (no sweep in from
 * offscreen), and focuses — contracts springily — while the pointer is pressed.
 * A faint center dot marks the true pointer so targets stay clickable.
 *
 * Wrap any content: `<SpotlightCursor>…surface…</SpotlightCursor>`. The overlay
 * is pointer-events-none. Gated on fine-pointer devices; before the pointer
 * enters, the surface is fully revealed so it never looks broken. Reduced
 * motion drops the spring lag but keeps the reveal.
 */
export interface SpotlightCursorProps extends React.ComponentProps<"div"> {
  /** Spotlight radius in px. */
  radius?: number;
  /** Edge softness, 0–1 — higher feathers the beam further inward. */
  softness?: number;
  /** How dark the un-lit area gets, 0–1. */
  dim?: number;
}

const MASK_SPRING: SpringOptions = { stiffness: 260, damping: 30, mass: 0.6 };
const PRESS_SPRING: SpringOptions = { stiffness: 500, damping: 30, mass: 0.5 };

export function SpotlightCursor({
  radius = 120,
  softness = 0.5,
  dim = 0.72,
  className,
  children,
  ...props
}: SpotlightCursorProps) {
  const hostRef = React.useRef<HTMLDivElement>(null);
  const [fine, setFine] = React.useState(false);
  const [inside, setInside] = React.useState(false);
  const insideRef = React.useRef(false);
  const reduced = useReducedMotion();

  const x = useMotionValue(-9999);
  const y = useMotionValue(-9999);
  const mx = useSpring(x, MASK_SPRING);
  const my = useSpring(y, MASK_SPRING);

  // Pressing "focuses" the light: the beam contracts on a spring.
  const press = useMotionValue(1);
  const beamR = useTransform(press, (p) => Math.max(8, radius * p));
  const beamInner = useTransform(beamR, (r) =>
    Math.max(0, Math.round(r * (1 - softness))),
  );

  // Feathered mask that punches a transparent hole in the dimming overlay:
  // transparent inside the beam → opaque (#000) outside → dim shows there.
  const holeMask = useMotionTemplate`radial-gradient(circle ${beamR}px at ${mx}px ${my}px, transparent 0px, transparent ${beamInner}px, #000 ${beamR}px)`;

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  React.useEffect(() => {
    const el = hostRef.current;
    if (!el || !fine) return;

    const enterAt = (px: number, py: number) => {
      x.jump(px);
      y.jump(py);
      mx.jump(px);
      my.jump(py);
      insideRef.current = true;
      setInside(true);
    };

    const onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      const px = e.clientX - rect.left;
      const py = e.clientY - rect.top;
      if (!insideRef.current) {
        enterAt(px, py);
        return;
      }
      if (reduced) {
        // 1:1 tracking, no glide.
        x.jump(px);
        y.jump(py);
        mx.jump(px);
        my.jump(py);
        return;
      }
      x.set(px);
      y.set(py);
    };
    const onEnter = (e: PointerEvent) => onMove(e);
    const onLeave = () => {
      insideRef.current = false;
      setInside(false);
      animate(press, 1, PRESS_SPRING);
    };
    const onDown = () => animate(press, 0.86, PRESS_SPRING);
    const onUp = () => animate(press, 1, PRESS_SPRING);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    el.addEventListener("pointerdown", onDown);
    el.addEventListener("pointerup", onUp);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
      el.removeEventListener("pointerdown", onDown);
      el.removeEventListener("pointerup", onUp);
    };
  }, [fine, reduced, x, y, mx, my, press]);

  const dimBg = `color-mix(in oklch, var(--color-background) ${Math.round(dim * 100)}%, transparent)`;

  return (
    <div
      ref={hostRef}
      className={cn(
        "relative overflow-hidden",
        fine && "[&_*]:cursor-none",
        className,
      )}
      style={fine ? { cursor: "none" } : undefined}
      {...props}
    >
      {children}

      {fine && (
        <>
          <motion.div
            aria-hidden
            className="pointer-events-none absolute inset-0 z-40"
            style={{
              background: dimBg,
              backdropFilter: "saturate(0.35) brightness(0.92)",
              WebkitBackdropFilter: "saturate(0.35) brightness(0.92)",
              // Always masked: on leave the hole stays parked at the exit
              // point while the overlay fades, instead of snapping shut and
              // flashing the just-lit area dim mid-fade.
              maskImage: holeMask,
              WebkitMaskImage: holeMask,
              opacity: inside ? 1 : 0,
              transition: "opacity 220ms ease",
            }}
          />
          {/* True-pointer dot so targeting never suffers under the beam. */}
          <motion.span
            aria-hidden
            className="pointer-events-none absolute top-0 left-0 z-50 rounded-full bg-foreground"
            style={{
              x,
              y,
              width: 4,
              height: 4,
              marginLeft: -2,
              marginTop: -2,
              opacity: inside ? 0.55 : 0,
              transition: "opacity 220ms ease",
            }}
          />
        </>
      )}
    </div>
  );
}