A bento dot grid whose cells illuminate near the pointer with a soft radial falloff, driven by per-frame CSS custom properties.
npx shadcn@latest add @paragon/spotlight-grid"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* SpotlightGrid — a bento/dot grid where each cell illuminates as the pointer
* passes near it, with a soft radial falloff. The pointer position is written
* once per frame to a CSS custom property on the grid; each cell computes its
* own glow from its center distance, so there is no per-cell React state and no
* layout thrash.
*
* Gated to `(hover: hover) and (pointer: fine)` — touch devices get the static
* grid. Fully token-driven and works in both themes. Accepts `children` to lay
* your own cells over the illuminated dot field, or renders a generated grid.
*/
export interface SpotlightGridProps extends React.ComponentProps<"div"> {
/** Illumination color. */
color?: string;
/** Glow radius in px. */
radius?: number;
/** Cell size in px (also the dot spacing). */
gridSize?: number;
/** Max glow opacity at the pointer. */
intensity?: number;
}
export function SpotlightGrid({
color = "var(--color-primary)",
radius = 140,
gridSize = 48,
intensity = 0.9,
className,
style,
children,
...props
}: SpotlightGridProps) {
const ref = React.useRef<HTMLDivElement>(null);
const raf = React.useRef<number | null>(null);
const pending = React.useRef<{ x: number; y: number } | null>(null);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
const flush = () => {
raf.current = null;
const p = pending.current;
if (!p) return;
el.style.setProperty("--sx", `${p.x}px`);
el.style.setProperty("--sy", `${p.y}px`);
};
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
pending.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
el.style.setProperty("--on", "1");
if (raf.current === null) raf.current = requestAnimationFrame(flush);
};
const onLeave = () => {
el.style.setProperty("--on", "0");
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
if (raf.current !== null) cancelAnimationFrame(raf.current);
};
}, []);
return (
<div
ref={ref}
data-slot="spotlight-grid"
className={cn(
"relative overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
style={
{
"--spot": color,
"--r": `${radius}px`,
"--g": `${gridSize}px`,
"--i": intensity,
"--on": "0",
"--sx": "-9999px",
"--sy": "-9999px",
...style,
} as React.CSSProperties
}
{...props}
>
{/* Base dot field */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-60"
style={{
backgroundImage:
"radial-gradient(circle at center, color-mix(in oklch, var(--color-foreground) 20%, transparent) 1px, transparent 1.5px)",
backgroundSize: "var(--g) var(--g)",
backgroundPosition: "center",
}}
/>
{/* Illuminated dot field, masked to the spotlight */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 transition-opacity duration-200 ease-out"
style={{
opacity: "var(--on)",
backgroundImage:
"radial-gradient(circle at center, var(--spot) 1.4px, transparent 2px)",
backgroundSize: "var(--g) var(--g)",
backgroundPosition: "center",
maskImage:
"radial-gradient(var(--r) var(--r) at var(--sx) var(--sy), rgba(0,0,0,var(--i)) 0%, transparent 72%)",
WebkitMaskImage:
"radial-gradient(var(--r) var(--r) at var(--sx) var(--sy), rgba(0,0,0,var(--i)) 0%, transparent 72%)",
}}
/>
{/* Soft wash glow following the pointer */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 transition-opacity duration-200 ease-out"
style={{
opacity: "var(--on)",
background:
"radial-gradient(var(--r) var(--r) at var(--sx) var(--sy), color-mix(in oklch, var(--spot) 12%, transparent) 0%, transparent 70%)",
}}
/>
{children && <div className="relative">{children}</div>}
</div>
);
}