"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function toRgb(input: string, fallback: [number, number, number]): [number, number, number] {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input;
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
export type SpoilerTextMode = "inline" | "block";
export interface SpoilerTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "onChange"> {
/** The hidden content. Inline mode expects phrasing content; block mode accepts paragraphs. */
children: React.ReactNode;
/** `inline` flows inside a sentence (multi-line aware); `block` covers a whole panel. */
mode?: SpoilerTextMode;
/** Controlled revealed state. */
revealed?: boolean;
/** Initial revealed state when uncontrolled. */
defaultRevealed?: boolean;
/** Called when the spoiler is revealed or hidden again. */
onRevealedChange?: (revealed: boolean) => void;
/** Allow clicking a revealed spoiler to cover it again. When false the spoiler becomes plain text. */
rehide?: boolean;
/** Particle density multiplier (1 ≈ one particle per 12px²). */
density?: number;
/** Drift speed multiplier for the shimmering particles. 0 freezes them. */
speed?: number;
/** Duration of the outward dissolve, in ms. */
revealDuration?: number;
/** Focus ring and spark colour (any CSS colour). */
accent?: string;
/** Accessible name of the button while the content is hidden. */
label?: string;
/** Small caption shown over a hidden block spoiler. */
hint?: string;
}
type Particle = {
/** Home rect index and position inside the canvas, in CSS px. */
r: number;
x: number;
y: number;
vx: number;
vy: number;
size: number;
phase: number;
twinkle: number;
spark: boolean;
};
type Rect = { x: number; y: number; w: number; h: number };
type Phase = "hidden" | "revealing" | "revealed" | "hiding";
const HIDE_MS = 320;
/**
* Text hidden under shimmering particle noise. Clicking (or Enter/Space) dissolves the noise
* outward from the press point. Inline spoilers follow every line box via getClientRects, so
* they wrap naturally inside paragraphs. Until revealed, the content is aria-hidden and the
* element is a button named "Reveal spoiler".
*/
export function SpoilerText({
children,
mode = "inline",
revealed: revealedProp,
defaultRevealed = false,
onRevealedChange,
rehide = true,
density = 1,
speed = 1,
revealDuration = 650,
accent = "#FF7448",
label = "Reveal spoiler",
hint = "Spoiler · click to reveal",
className = "",
style,
onClick,
onKeyDown,
...props
}: SpoilerTextProps) {
const reduceMotion = usePrefersReducedMotion();
const [inner, setInner] = React.useState(defaultRevealed);
const revealed = revealedProp ?? inner;
const block = mode === "block";
const interactive = !revealed || rehide;
const rootRef = React.useRef<HTMLElement | null>(null);
const textRef = React.useRef<HTMLElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
/** Mutable animation state shared by the effect and the event handlers (never read in render). */
const anim = React.useRef({
phase: (defaultRevealed ? "revealed" : "hidden") as Phase,
start: 0,
ox: 0,
oy: 0,
maxDist: 1,
kick: () => {},
});
const setRoot = (node: HTMLElement | null) => {
rootRef.current = node;
};
const setText = (node: HTMLElement | null) => {
textRef.current = node;
};
const commit = (next: boolean) => {
if (revealedProp === undefined) setInner(next);
onRevealedChange?.(next);
};
/** Starts the dissolve from a client-space point (or the centre for keyboard activation). */
const reveal = (clientX?: number, clientY?: number) => {
const root = rootRef.current;
const canvas = canvasRef.current;
const a = anim.current;
if (!root || !canvas) return;
const box = canvas.getBoundingClientRect();
const bounds = root.getBoundingClientRect();
const cx = clientX ?? bounds.left + bounds.width / 2;
const cy = clientY ?? bounds.top + bounds.height / 2;
a.ox = cx - box.left;
a.oy = cy - box.top;
a.maxDist = Math.max(
Math.hypot(a.ox, a.oy),
Math.hypot(box.width - a.ox, a.oy),
Math.hypot(a.ox, box.height - a.oy),
Math.hypot(box.width - a.ox, box.height - a.oy),
1,
);
a.start = performance.now();
a.phase = reduceMotion || revealDuration === 0 ? "revealed" : "revealing";
// Block mode: the text is unveiled by a radial mask that grows with the wave.
const text = textRef.current;
if (block && text && a.phase === "revealing") {
const tb = text.getBoundingClientRect();
text.style.setProperty("--sp-x", `${Math.round(cx - tb.left)}px`);
text.style.setProperty("--sp-y", `${Math.round(cy - tb.top)}px`);
text.style.setProperty("--sp-r", "0px");
const mask = "radial-gradient(circle at var(--sp-x) var(--sp-y), #000 var(--sp-r), transparent calc(var(--sp-r) + 48px))";
text.style.maskImage = mask;
text.style.webkitMaskImage = mask;
}
a.kick();
commit(true);
};
const hide = () => {
const a = anim.current;
a.start = performance.now();
a.phase = reduceMotion ? "hidden" : "hiding";
a.kick();
commit(false);
};
// Keep the animation phase in step with controlled changes made outside the component.
React.useEffect(() => {
const a = anim.current;
if (revealed && (a.phase === "hidden" || a.phase === "hiding")) {
a.phase = "revealed";
a.kick();
} else if (!revealed && (a.phase === "revealed" || a.phase === "revealing")) {
a.phase = "hidden";
a.kick();
}
}, [revealed]);
/* ---------------------------------- canvas --------------------------------- */
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const a = anim.current;
let particles: Particle[] = [];
let rects: Rect[] = [];
let width = 0;
let height = 0;
let dpr = 1;
let ink: [number, number, number] = [113, 113, 122];
let spark: [number, number, number] = [255, 116, 72];
let raf = 0;
let last = 0;
let lastColour = 0;
let onScreen = true;
const resolveColours = () => {
const cs = getComputedStyle(root);
ink = toRgb(cs.color, ink);
spark = toRgb(cs.getPropertyValue("--sp-accent").trim() || accent, spark);
};
/** Places the canvas over the union of the line boxes and seeds particles per line. */
const measure = () => {
const lines = Array.from(root.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5);
if (!lines.length) return;
canvas.style.left = "0px";
canvas.style.top = "0px";
const origin = canvas.getBoundingClientRect();
const left = Math.min(...lines.map((r) => r.left));
const top = Math.min(...lines.map((r) => r.top));
width = Math.max(...lines.map((r) => r.right)) - left;
height = Math.max(...lines.map((r) => r.bottom)) - top;
canvas.style.left = `${Math.round((left - origin.left) * 100) / 100}px`;
canvas.style.top = `${Math.round((top - origin.top) * 100) / 100}px`;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
rects = lines.map((r) => ({ x: r.left - left, y: r.top - top, w: r.width, h: r.height }));
particles = [];
const per = 12 / Math.max(0.1, density);
rects.forEach((rect, ri) => {
const count = Math.min(2500, Math.round((rect.w * rect.h) / per));
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const v = 4 + Math.random() * 10;
particles.push({
r: ri,
x: rect.x + Math.random() * rect.w,
y: rect.y + Math.random() * rect.h,
vx: Math.cos(angle) * v,
vy: Math.sin(angle) * v,
size: Math.random() < 0.25 ? 1.6 : 1.1,
phase: Math.random() * Math.PI * 2,
twinkle: 1.5 + Math.random() * 3,
spark: Math.random() < 0.08,
});
}
});
resolveColours();
draw(performance.now(), 0);
};
const draw = (now: number, dt: number) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (a.phase === "revealed") return;
const t = now / 1000;
const animated = !reduceMotion && speed > 0;
let wave = -1;
let band = 1;
let fadeIn = 1;
if (a.phase === "revealing") {
const p = Math.min(1, (now - a.start) / revealDuration);
band = a.maxDist * 0.35;
wave = (1 - Math.pow(1 - p, 2)) * (a.maxDist + band);
const text = textRef.current;
if (block && text) text.style.setProperty("--sp-r", `${Math.round(wave)}px`);
} else if (a.phase === "hiding") {
fadeIn = Math.min(1, (now - a.start) / HIDE_MS);
}
for (const p of particles) {
const rect = rects[p.r];
if (!rect) continue;
if (animated && a.phase === "hidden") {
p.x += p.vx * speed * dt;
p.y += p.vy * speed * dt;
// Wrap inside the particle's own line box so inline spoilers never bleed between lines.
if (p.x < rect.x) p.x += rect.w;
else if (p.x > rect.x + rect.w) p.x -= rect.w;
if (p.y < rect.y) p.y += rect.h;
else if (p.y > rect.y + rect.h) p.y -= rect.h;
}
let alpha = animated ? 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(t * p.twinkle * speed + p.phase)) : 0.4 + 0.6 * ((p.phase / Math.PI) % 1);
let x = p.x;
let y = p.y;
if (wave >= 0) {
const dx = p.x - a.ox;
const dy = p.y - a.oy;
const dist = Math.hypot(dx, dy) || 1;
const local = Math.min(1, Math.max(0, (wave - dist) / band));
if (local >= 1) continue;
alpha *= 1 - local;
// Particles inside the wave are flung outward as they fade.
const push = local * local * 22;
x += (dx / dist) * push;
y += (dy / dist) * push;
}
alpha *= fadeIn;
const [r, g, b] = p.spark ? spark : ink;
ctx.fillStyle = `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
ctx.fillRect(x, y, p.size, p.size);
}
};
const running = () =>
onScreen && !document.hidden && (a.phase === "revealing" || a.phase === "hiding" || (a.phase === "hidden" && !reduceMotion && speed > 0));
const tick = (now: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
last = now;
if (now - lastColour > 600) {
lastColour = now;
resolveColours(); // follows theme switches without observers
}
if (a.phase === "revealing" && now - a.start >= revealDuration) {
a.phase = "revealed";
const text = textRef.current;
if (text) {
text.style.maskImage = "";
text.style.webkitMaskImage = "";
}
} else if (a.phase === "hiding" && now - a.start >= HIDE_MS) {
a.phase = "hidden";
}
draw(now, dt);
if (running()) raf = requestAnimationFrame(tick);
};
/** (Re)starts the loop when needed, or paints a single frame for static states. */
const kick = () => {
if (a.phase === "revealed") {
const text = textRef.current;
if (text) {
text.style.maskImage = "";
text.style.webkitMaskImage = "";
}
}
if (running()) {
if (!raf) {
last = 0;
raf = requestAnimationFrame(tick);
}
} else {
cancelAnimationFrame(raf);
raf = 0;
draw(performance.now(), 0);
}
};
a.kick = kick;
measure();
kick();
// Inline elements don't report sizes to ResizeObserver, so watch the parent block instead.
const ro = new ResizeObserver(() => {
measure();
kick();
});
ro.observe(root.parentElement ?? root);
if (block) ro.observe(root);
const io = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
kick();
});
io.observe(root);
const onVisibility = () => kick();
document.addEventListener("visibilitychange", onVisibility);
let disposed = false;
document.fonts?.ready.then(() => {
if (!disposed) {
measure();
kick();
}
});
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
a.kick = () => {};
};
}, [block, density, speed, revealDuration, reduceMotion, accent]);
/* --------------------------------- events --------------------------------- */
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (!revealed) {
// detail === 0 means a keyboard-synthesised click: dissolve from the centre.
if (event.detail === 0) reveal();
else reveal(event.clientX, event.clientY);
} else if (rehide) {
// Don't cover the text again while the reader is selecting it.
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
hide();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || !interactive || event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!revealed) reveal();
else hide();
}
};
/* --------------------------------- render --------------------------------- */
const fade = reduceMotion ? "none" : block ? "opacity 160ms ease-out" : `opacity ${Math.round(revealDuration * 0.85)}ms cubic-bezier(0.2, 0.7, 0.2, 1) ${Math.round(revealDuration * 0.1)}ms`;
const shared = {
ref: setRoot,
...props,
role: interactive ? "button" : undefined,
tabIndex: interactive ? 0 : -1,
"aria-expanded": interactive ? revealed : undefined,
"aria-label": revealed ? props["aria-label"] : label,
"data-state": revealed ? "revealed" : "hidden",
onClick: handleClick,
onKeyDown: handleKeyDown,
style: { ["--sp-accent" as string]: accent, ...style },
};
const canvas = (
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0"
/>
);
const ring =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sp-accent)]";
if (block) {
return (
<div
{...shared}
className={`group/spoiler relative block overflow-hidden rounded-xl border p-5 text-left transition-[background-color,border-color] duration-300 motion-reduce:transition-none ${
revealed
? "border-zinc-900/10 bg-transparent dark:border-white/10"
: "cursor-pointer border-zinc-900/10 bg-zinc-900/[0.04] hover:bg-zinc-900/[0.06] dark:border-white/10 dark:bg-white/[0.04] dark:hover:bg-white/[0.06]"
} ${revealed && rehide ? "cursor-pointer" : ""} ${interactive ? ring : ""} ${className}`}
>
<div
ref={setText}
aria-hidden={revealed ? undefined : true}
className={revealed ? "" : "select-none"}
style={{ opacity: revealed ? 1 : 0, transition: fade }}
>
{children}
</div>
{canvas}
{hint && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-200 motion-reduce:transition-none ${
revealed ? "opacity-0" : "opacity-100"
}`}
>
<span className="rounded-full border border-zinc-900/10 bg-white/90 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 shadow-[0_6px_18px_-10px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900/90 dark:text-zinc-300">
{hint}
</span>
</span>
)}
</div>
);
}
return (
<span
{...shared}
className={`relative rounded-[5px] px-[3px] [box-decoration-break:clone] [-webkit-box-decoration-break:clone] transition-[background-color] duration-300 motion-reduce:transition-none ${
revealed
? "bg-transparent"
: "cursor-pointer bg-zinc-900/[0.07] hover:bg-zinc-900/[0.1] dark:bg-white/[0.08] dark:hover:bg-white/[0.12]"
} ${revealed && rehide ? "cursor-pointer" : ""} ${interactive ? ring : ""} ${className}`}
>
<span
ref={setText}
aria-hidden={revealed ? undefined : true}
className={revealed ? "" : "select-none"}
style={{ opacity: revealed ? 1 : 0, transition: fade }}
>
{children}
</span>
{canvas}
</span>
);
}