A soft radial spotlight that follows the cursor across any container on hover-capable devices.
npx shadcn@latest add @paragon/spotlight"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SpotlightProps extends React.ComponentProps<"div"> {
/** Radius of the spotlight in px. Generous by default so the falloff stays soft. */
size?: number;
/**
* Spotlight color. Defaults to a theme-aware foreground tint that reads as a
* subtle sheen in light mode and a soft glow in dark mode.
*/
color?: string;
}
/**
* A soft radial spotlight that follows the cursor across the parent element.
*
* Technique: pointer coordinates are written directly to this overlay's own
* CSS variables (never a parent's — no style-recalc storm), and a generously
* sized, blur-free radial-gradient repaints around them. Listeners attach only
* on hover-capable, fine-pointer devices; the reveal/hide is an interruptible
* opacity transition. Absolutely positioned — parent needs position: relative
* (and overflow-hidden if it has rounded corners the glow should clip to).
*/
export function Spotlight({
size = 320,
color,
className,
style,
ref,
...props
}: SpotlightProps) {
const localRef = React.useRef<HTMLDivElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLDivElement | null) => {
localRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
React.useEffect(() => {
const node = localRef.current;
const parent = node?.parentElement;
if (!node || !parent) return;
// Hover-only effect: attach nothing on touch / coarse-pointer devices.
const media = window.matchMedia("(hover: hover) and (pointer: fine)");
const move = (event: PointerEvent) => {
const rect = parent.getBoundingClientRect();
// Written straight onto the overlay element — only its own paint updates.
node.style.setProperty("--spotlight-x", `${event.clientX - rect.left}px`);
node.style.setProperty("--spotlight-y", `${event.clientY - rect.top}px`);
};
const enter = (event: PointerEvent) => {
move(event); // position before revealing so the light never sweeps in from a stale spot
node.style.transitionDuration = "var(--duration-base)";
node.style.transitionTimingFunction = "var(--ease-out)";
node.style.opacity = "1";
};
const leave = () => {
node.style.transitionDuration = "var(--duration-quick)";
node.style.transitionTimingFunction = "var(--ease-exit)";
node.style.opacity = "0";
};
const attach = () => {
parent.addEventListener("pointerenter", enter);
parent.addEventListener("pointermove", move);
parent.addEventListener("pointerleave", leave);
};
const detach = () => {
parent.removeEventListener("pointerenter", enter);
parent.removeEventListener("pointermove", move);
parent.removeEventListener("pointerleave", leave);
leave(); // hide gracefully if the gate flips mid-hover
};
if (media.matches) attach();
const onGateChange = (event: MediaQueryListEvent) =>
event.matches ? attach() : detach();
media.addEventListener("change", onGateChange);
return () => {
media.removeEventListener("change", onGateChange);
detach();
};
}, []);
return (
<div
ref={setRefs}
aria-hidden
data-slot="spotlight"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity",
// Theme-aware default: faint sheen on light surfaces, soft glow on dark.
"[--spotlight-color:color-mix(in_oklab,var(--foreground)_6%,transparent)]",
"dark:[--spotlight-color:color-mix(in_oklab,var(--foreground)_10%,transparent)]",
className,
)}
style={
{
...(color ? { "--spotlight-color": color } : null),
background: `radial-gradient(${size}px circle at var(--spotlight-x, 50%) var(--spotlight-y, 50%), var(--spotlight-color), transparent 70%)`,
transitionDuration: "var(--duration-base)",
transitionTimingFunction: "var(--ease-out)",
...style,
} as React.CSSProperties
}
{...props}
/>
);
}