Content sits under a duotone cover and the pointer's spotlight reveals the real colored content beneath.
npx shadcn@latest add @paragon/cursor-spotlight-reveal"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>
);
}