"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SpotlightGridProps extends React.HTMLAttributes<HTMLDivElement> {
/** Spotlight colour (any CSS colour). Defaults to the theme primary. */
color?: string;
/** Radius of the border glow in px. */
radius?: number;
/** Where the light rests before the first hover, as fractions of the grid (null = hidden until hovered). */
idlePosition?: { x: number; y: number } | null;
/** Glow strength (0–1) while the pointer is outside the grid. */
idleOpacity?: number;
}
/**
* Tracks the pointer once for the whole grid and hands every `SpotlightCard`
* its own local coordinates — so one light moves across all cards and their borders.
*/
export function SpotlightGrid({
color = "var(--primary)",
radius = 360,
idlePosition = { x: 0.3, y: 0 },
idleOpacity = 0.55,
className,
style,
children,
...props
}: SpotlightGridProps) {
const ref = React.useRef<HTMLDivElement>(null);
const frame = React.useRef(0);
const place = React.useCallback((clientX: number, clientY: number) => {
const cards = ref.current?.querySelectorAll<HTMLElement>("[data-spotlight-card]") ?? [];
cards.forEach((card) => {
const r = card.getBoundingClientRect();
card.style.setProperty("--spot-x", `${clientX - r.left}px`);
card.style.setProperty("--spot-y", `${clientY - r.top}px`);
});
}, []);
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const { clientX, clientY } = e;
cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => place(clientX, clientY));
};
// Park the light at its idle position so the grid glows before anyone hovers it.
const idleX = idlePosition?.x;
const idleY = idlePosition?.y;
React.useEffect(() => {
const el = ref.current;
if (!el || idleX === undefined || idleY === undefined) return;
const put = () => {
const r = el.getBoundingClientRect();
place(r.left + r.width * idleX, r.top + r.height * idleY);
};
put();
const ro = new ResizeObserver(put);
ro.observe(el);
return () => {
ro.disconnect();
cancelAnimationFrame(frame.current);
};
}, [idleX, idleY, place]);
return (
<div
ref={ref}
onPointerMove={onPointerMove}
className={cn("group/spotlight grid gap-4", className)}
style={
{
...style,
"--spot-color": color,
"--spot-radius": `${radius}px`,
"--spot-idle": idlePosition ? idleOpacity : 0,
} as React.CSSProperties
}
{...props}
>
{children}
</div>
);
}
export interface SpotlightCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Class for the inner surface (padding, layout). */
contentClassName?: string;
}
export function SpotlightCard({ className, contentClassName, children, ...props }: SpotlightCardProps) {
return (
<div
data-spotlight-card
className={cn(
"relative rounded-2xl bg-border p-px",
"[--spot-x:-999px] [--spot-y:-999px]",
className,
)}
{...props}
>
{/* Border glow: shows through the 1px gap around the surface. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-[var(--spot-idle,0)] transition-opacity duration-500 group-hover/spotlight:opacity-100"
style={{
background:
"radial-gradient(var(--spot-radius) circle at var(--spot-x) var(--spot-y), var(--spot-color), transparent 50%)",
}}
/>
<div className={cn("relative h-full overflow-hidden rounded-[calc(1rem-1px)] bg-card text-card-foreground", contentClassName)}>
{/* Soft surface light, weaker than the border. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[var(--spot-idle,0)] transition-opacity duration-500 group-hover/spotlight:opacity-100"
style={{
background:
"radial-gradient(calc(var(--spot-radius) * 0.9) circle at var(--spot-x) var(--spot-y), color-mix(in oklab, var(--spot-color) 16%, transparent), transparent 60%)",
}}
/>
<div className="relative h-full">{children}</div>
</div>
</div>
);
}