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 {
  motion,
  useMotionTemplate,
  useMotionValue,
  useReducedMotion,
  useSpring,
  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 follows on
 * a soft spring so the light glides. Great for feature tours and "explore"
 * surfaces.
 *
 * 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 };

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 reduced = useReducedMotion();

  const x = useMotionValue(-9999);
  const y = useMotionValue(-9999);
  const mx = useSpring(x, MASK_SPRING);
  const my = useSpring(y, MASK_SPRING);
  const px = reduced ? x : mx;
  const py = reduced ? y : my;

  // Feathered mask that punches a transparent hole in the dimming overlay:
  // transparent inside the beam → opaque (#000) outside → dim shows there.
  const inner = Math.max(0, Math.round(radius * (1 - softness)));
  const holeMask = useMotionTemplate`radial-gradient(circle ${radius}px at ${px}px ${py}px, transparent 0px, transparent ${inner}px, #000 ${radius}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 onMove = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      x.set(e.clientX - rect.left);
      y.set(e.clientY - rect.top);
    };
    const onEnter = () => setInside(true);
    const onLeave = () => setInside(false);
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerenter", onEnter);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerenter", onEnter);
      el.removeEventListener("pointerleave", onLeave);
    };
  }, [fine, x, y]);

  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)",
            maskImage: inside ? holeMask : undefined,
            WebkitMaskImage: inside ? holeMask : undefined,
            opacity: inside ? 1 : 0,
            transition: "opacity 220ms ease",
          }}
        />
      )}
    </div>
  );
}