"use client";
import * as React from "react";
export type FlashlightTouchBehavior = "follow" | "reveal";
export interface FlashlightRevealProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
/** Content revealed by the light. Always rendered and always available to assistive tech. */
children: React.ReactNode;
/** Optional decorative content drawn on the dark layer (hidden from assistive tech). */
cover?: React.ReactNode;
/** Radius of the light, in px. */
radius?: number;
/** Edge softness from 0 (hard edge) to 1 (fully feathered). */
softness?: number;
/** Follow lag from 0 (instant) to 0.95 (very floaty). */
smoothing?: number;
/** Opacity of the dark layer. */
darkness?: number;
/** Colour of the dark layer. */
overlayColor?: string;
/** Warm tint inside the light. */
glow?: string;
/** Show the "Reveal all" toggle (the keyboard and touch alternative). */
showToggle?: boolean;
/** Touch: the light follows the finger, or a tap reveals everything. */
touchBehavior?: FlashlightTouchBehavior;
/** Controlled full reveal. */
revealed?: boolean;
/** Initial full reveal (uncontrolled). */
defaultRevealed?: boolean;
onRevealedChange?: (revealed: boolean) => void;
/** Visible label of the toggle. */
toggleLabel?: string;
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const MASK =
"radial-gradient(circle at var(--fr-x) var(--fr-y), transparent calc(var(--fr-r) * var(--fr-inner)), #000 var(--fr-r))";
/**
* A dark layer with a soft circular light that follows the pointer and reveals the content
* beneath. The reveal is purely visual: the content is ordinary DOM underneath an
* aria-hidden, pointer-transparent overlay, so it stays readable, focusable and clickable.
* Position and radius are eased in a short rAF loop that stops as soon as the light settles,
* and are written to CSS variables (no re-renders while moving).
*/
export function FlashlightReveal({
children,
cover,
radius = 150,
softness = 0.6,
smoothing = 0.8,
darkness = 0.94,
overlayColor = "#0A0B0D",
glow = "#FF7448",
showToggle = true,
touchBehavior = "follow",
revealed: revealedProp,
defaultRevealed = false,
onRevealedChange,
toggleLabel = "Reveal all",
className = "",
style,
...props
}: FlashlightRevealProps) {
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState(defaultRevealed);
const revealed = revealedProp ?? internal;
const rootRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
// Light state survives effect re-runs (e.g. toggling `revealed`), so changes animate from where it is.
const lightRef = React.useRef({ x: 0, y: 0, r: 0, tx: 0, ty: 0, tr: 0, w: 0, h: 0, lit: false });
const setRevealed = (next: boolean) => {
if (next === revealed) return;
if (revealedProp === undefined) setInternal(next);
onRevealedChange?.(next);
};
// The touch "reveal" behaviour needs the latest setter inside the long-lived effect below.
const revealRef = React.useRef(setRevealed);
React.useEffect(() => {
revealRef.current = setRevealed;
});
React.useEffect(() => {
const root = rootRef.current;
const content = contentRef.current;
if (!root || !content) return;
const s = lightRef.current; // `s.lit`: pointer, touch or focus currently holds the light
let raf = 0;
let last = 0;
const fullRadius = () => Math.hypot(s.w, s.h) + radius;
const write = () => {
root.style.setProperty("--fr-x", `${s.x.toFixed(1)}px`);
root.style.setProperty("--fr-y", `${s.y.toFixed(1)}px`);
root.style.setProperty("--fr-r", `${Math.max(0, s.r).toFixed(1)}px`);
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
const lag = Math.min(0.95, Math.max(0, smoothing));
const a = reduceMotion ? 1 : 1 - Math.pow(lag, dt / 16.7);
s.x += (s.tx - s.x) * a;
s.y += (s.ty - s.y) * a;
s.r += (s.tr - s.r) * a;
const settled = Math.abs(s.tx - s.x) < 0.3 && Math.abs(s.ty - s.y) < 0.3 && Math.abs(s.tr - s.r) < 0.3;
if (settled) {
s.x = s.tx;
s.y = s.ty;
s.r = s.tr;
}
write();
if (!settled && !document.hidden) raf = requestAnimationFrame(frame);
else last = 0;
};
const kick = () => {
if (!raf) raf = requestAnimationFrame(frame);
};
const aimRadius = () => {
s.tr = revealed ? fullRadius() : s.lit ? radius : 0;
};
const measure = () => {
const rect = root.getBoundingClientRect();
const first = s.w === 0;
s.w = rect.width;
s.h = rect.height;
if (first) {
s.x = s.tx = s.w / 2;
s.y = s.ty = s.h / 2;
s.r = revealed ? fullRadius() : 0;
}
aimRadius();
kick();
};
const moveTo = (clientX: number, clientY: number) => {
const rect = root.getBoundingClientRect();
s.tx = clientX - rect.left;
s.ty = clientY - rect.top;
if (!s.lit) {
// Entering: start the light where the pointer is instead of sweeping across.
s.lit = true;
if (s.r < 1) {
s.x = s.tx;
s.y = s.ty;
}
}
aimRadius();
kick();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && touchBehavior !== "follow") return;
moveTo(e.clientX, e.clientY);
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
if (touchBehavior === "reveal") revealRef.current(true);
else moveTo(e.clientX, e.clientY);
};
const onPointerLeave = (e: PointerEvent) => {
// On touch the light stays where the finger lifted.
if (e.pointerType === "touch") return;
s.lit = false;
aimRadius();
kick();
};
// Keyboard users: the light follows focus within the revealed content.
const onFocusIn = (e: FocusEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement) || !target.matches(":focus-visible")) return;
const rect = target.getBoundingClientRect();
const host = root.getBoundingClientRect();
s.tx = rect.left + rect.width / 2 - host.left;
s.ty = rect.top + rect.height / 2 - host.top;
s.lit = true;
s.tr = revealed ? fullRadius() : Math.max(radius, Math.hypot(rect.width, rect.height) / 2 + 28);
kick();
};
const onFocusOut = (e: FocusEvent) => {
if (e.relatedTarget instanceof Node && content.contains(e.relatedTarget)) return;
s.lit = false;
aimRadius();
kick();
};
const onVisibility = () => {
if (!document.hidden) kick();
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(root);
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerdown", onPointerDown, { passive: true });
root.addEventListener("pointerleave", onPointerLeave);
content.addEventListener("focusin", onFocusIn);
content.addEventListener("focusout", onFocusOut);
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointerleave", onPointerLeave);
content.removeEventListener("focusin", onFocusIn);
content.removeEventListener("focusout", onFocusOut);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [radius, smoothing, touchBehavior, revealed, reduceMotion]);
const inner = 1 - Math.min(1, Math.max(0, softness));
return (
<div
ref={rootRef}
className={`absolute inset-0 isolate overflow-hidden [--fr-r:0px] [--fr-x:50%] [--fr-y:50%] ${className}`}
style={
{
"--fr-inner": inner.toFixed(3),
"--fr-glow": glow,
...style,
} as React.CSSProperties
}
{...props}
>
<div ref={contentRef} className="relative h-full w-full">
{children}
</div>
{/* Dark layer with a hole cut by the mask. Visual only: hidden from AT, transparent to pointers. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ maskImage: MASK, WebkitMaskImage: MASK }}
>
<div className="absolute inset-0" style={{ background: overlayColor, opacity: darkness }} />
{cover != null && <div className="absolute inset-0">{cover}</div>}
</div>
{/* Soft warm tint inside the light. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 mix-blend-soft-light transition-opacity duration-500 motion-reduce:transition-none"
style={{
background:
"radial-gradient(circle at var(--fr-x) var(--fr-y), color-mix(in srgb, var(--fr-glow) 55%, transparent) 0, transparent var(--fr-r))",
opacity: revealed ? 0 : 1,
}}
/>
{showToggle && (
<button
type="button"
aria-pressed={revealed}
onClick={() => setRevealed(!revealed)}
className="absolute bottom-3 right-3 z-10 inline-flex h-8 items-center gap-2 rounded-full border border-white/15 bg-zinc-950/70 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-200 backdrop-blur-sm transition-colors hover:border-white/35 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fr-glow)] aria-pressed:border-[var(--fr-glow)] aria-pressed:text-white"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.4">
<path d="M6 13.5h4M6.5 11.5h3M8 1.5a4.5 4.5 0 0 0-2.6 8.2c.4.3.6.7.6 1.1v.7h4v-.7c0-.4.2-.8.6-1.1A4.5 4.5 0 0 0 8 1.5Z" />
</svg>
{toggleLabel}
</button>
)}
</div>
);
}