The pointer carries a feathered radial spotlight that reveals content in full through a dimmed, desaturated overlay, gliding on a soft spring.
npx shadcn@latest add @paragon/spotlight-cursor"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>
);
}