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 {
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>
);
}