Effects & Borders

Cursor Spotlight Reveal

Content sits under a duotone cover and the pointer's spotlight reveals the real colored content beneath.

Install

npx shadcn@latest add @paragon/cursor-spotlight-reveal

cursor-spotlight-reveal.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * CursorSpotlightReveal — the same content is stacked twice: a muted duotone
 * (grayscale) layer on top, and the real colored content beneath. A radial mask
 * driven by the pointer punches a hole in the duotone layer, so a spotlight of
 * "real" content follows the cursor.
 *
 * The pointer position is written to CSS custom properties once per frame (no
 * React state churn). Gated to `(hover: hover) and (pointer: fine)` — on touch
 * both layers show the colored content. Pass the SAME children through `base`
 * (colored) and `overlay` (styled duotone) — or just `children` to auto-derive
 * a grayscale overlay via a CSS filter.
 */

export interface CursorSpotlightRevealProps
  extends React.ComponentProps<"div"> {
  /** Colored / revealed content (the truth beneath). */
  children: React.ReactNode;
  /** Spotlight radius in px. */
  radius?: number;
  /** Softness of the spotlight edge, 0–1 (higher = feathier). */
  softness?: number;
  /**
   * CSS filter applied to the covering layer. Defaults to a desaturated,
   * dimmed duotone. Override for a custom "hidden" look.
   */
  duotone?: string;
}

export function CursorSpotlightReveal({
  children,
  radius = 130,
  softness = 0.6,
  duotone = "grayscale(1) contrast(0.9) brightness(0.85) opacity(0.55)",
  className,
  style,
  ...props
}: CursorSpotlightRevealProps) {
  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) {
      el.style.setProperty("--reveal", "1"); // show colored fully on touch
      return;
    }

    const flush = () => {
      raf.current = null;
      const p = pending.current;
      if (!p) return;
      el.style.setProperty("--mx", `${p.x}px`);
      el.style.setProperty("--my", `${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);
    };
  }, []);

  const inner = 1 - Math.min(0.95, Math.max(0, softness));
  const mask = `radial-gradient(var(--r) var(--r) at var(--mx) var(--my), transparent ${
    inner * 60
  }%, rgba(0,0,0, var(--on)) 100%)`;

  return (
    <div
      ref={ref}
      data-slot="cursor-spotlight-reveal"
      className={cn("relative overflow-hidden", className)}
      style={
        {
          "--r": `${radius}px`,
          "--mx": "50%",
          "--my": "50%",
          "--on": "1",
          "--reveal": "0",
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      {/* Colored truth beneath */}
      <div className="relative">{children}</div>

      {/* Duotone cover with a spotlight hole */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0"
        style={{
          filter: duotone,
          maskImage: mask,
          WebkitMaskImage: mask,
          // when reveal=1 (touch), hide the cover entirely
          opacity: "calc(1 - var(--reveal))",
        }}
      >
        {children}
      </div>
    </div>
  );
}