Effects & Borders

Spotlight

A soft radial spotlight that follows the cursor across any container on hover-capable devices.

Install

npx shadcn@latest add @paragon/spotlight

spotlight.tsx

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