Effects & Borders

Spotlight Grid

A bento dot grid whose cells illuminate near the pointer with a soft radial falloff, driven by per-frame CSS custom properties.

Install

npx shadcn@latest add @paragon/spotlight-grid

spotlight-grid.tsx

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