"use client";
import * as React from "react";
export type TileRevealPattern = "corner" | "center" | "pointer" | "random" | "sweep";
export type TileRevealEffect = "flip" | "scale" | "drop";
export type TileRevealTone = "ink" | "accent" | "mixed";
export type TileRevealTrigger = "view" | "click" | "manual";
export interface TileRevealProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** The content under the cover. It is inert until the reveal finishes. */
children: React.ReactNode;
/** Tile rows. rows × cols is capped at 200 tiles. */
rows?: number;
/** Tile columns. */
cols?: number;
/** Where the wave starts: a corner, the centre, the pointer, random order, or a left-to-right sweep. */
pattern?: TileRevealPattern;
/** How each tile leaves. */
effect?: TileRevealEffect;
/** Total reveal time in ms, from the first tile moving to the last one gone. */
duration?: number;
/** Cover colouring: theme ink, the accent, or an ink checker with accent tiles. */
tone?: TileRevealTone;
/** Accent colour for the "accent" and "mixed" tones and the focus ring. */
accent?: string;
/** "view" reveals when scrolled into view, "click" waits for a press, "manual" follows `revealed`. */
trigger?: TileRevealTrigger;
/** With trigger="manual": true reveals, false covers again instantly. */
revealed?: boolean;
/** Accessible name and visible label of the cover button (trigger="click"). */
label?: string;
/** Called once the last tile is gone and the content is interactive. */
onReveal?: () => void;
}
/* ----------------------------- Reduced motion ------------------------------ */
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,
);
}
/* --------------------------------- Layout ---------------------------------- */
const MAX_TILES = 200;
/** Clamps the grid to at most 200 tiles by shrinking the larger side. */
function gridSize(rows: number, cols: number) {
let r = Math.max(1, Math.min(20, Math.round(rows)));
let c = Math.max(1, Math.min(20, Math.round(cols)));
while (r * c > MAX_TILES) {
if (c >= r) c--;
else r--;
}
return { r, c };
}
/** Deterministic 0–1 hash per tile, so "random" renders the same on the server and the client. */
function hash(i: number) {
let h = Math.imul(i + 0x9e3779b9, 0x85ebca6b);
h ^= h >>> 13;
h = Math.imul(h, 0xc2b2ae35);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
/**
* Each tile's start time as a fraction (0–1) of the wave. Distances are measured in tile units,
* so the wave front stays round on non-square grids.
*/
function waveOrder(pattern: TileRevealPattern, r: number, c: number, origin: [number, number] | null) {
const order: number[] = [];
const ox = origin ? origin[0] * c : c / 2;
const oy = origin ? origin[1] * r : r / 2;
const far = Math.max(Math.hypot(ox, oy), Math.hypot(c - ox, oy), Math.hypot(ox, r - oy), Math.hypot(c - ox, r - oy)) || 1;
for (let row = 0; row < r; row++) {
for (let col = 0; col < c; col++) {
const x = col + 0.5;
const y = row + 0.5;
let t: number;
if (pattern === "corner") t = (x + y) / (c + r);
else if (pattern === "random") t = hash(row * c + col);
else if (pattern === "sweep") t = (x + y * 0.18) / (c + r * 0.18);
else t = Math.hypot(x - ox, y - oy) / far; // center, pointer
order.push(t);
}
}
const min = Math.min(...order);
const max = Math.max(...order);
return order.map((t) => (max > min ? (t - min) / (max - min) : 0));
}
/** The end state of a tile per effect, as an inline transform. */
const GONE: Record<TileRevealEffect, string> = {
flip: "perspective(1400px) rotateY(-90deg)",
scale: "scale(0)",
drop: "translateY(60%) scale(0.86)",
};
const EASE_OUT = "cubic-bezier(0.65, 0, 0.35, 1)";
const EASE_FADE = "cubic-bezier(0.4, 0, 1, 1)";
const FADE_MS = 320; // reduced-motion cross-fade
/**
* A grid of tiles covering content, cleared in a wave — flipping, shrinking or dropping away from
* a corner, the centre, the pointer, at random or in a sweep — as an entrance or transition. The
* content is inert until the last tile is gone. Triggers on view, on click (the cover is a real
* button) or from the `revealed` prop. Reduced motion cross-fades the whole cover instead.
*/
export function TileReveal({
children,
rows = 6,
cols = 10,
pattern = "corner",
effect = "flip",
duration = 1100,
tone = "ink",
accent = "#FF7448",
trigger = "view",
revealed,
label = "Reveal",
onReveal,
className = "",
style,
onPointerMove,
...props
}: TileRevealProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const coverRef = React.useRef<HTMLButtonElement>(null);
const pointerRef = React.useRef<[number, number] | null>(null);
const refocusRef = React.useRef(false);
const initiallyOpen = trigger === "manual" && !!revealed;
const [open, setOpen] = React.useState(initiallyOpen);
const [settled, setSettled] = React.useState(initiallyOpen);
const [origin, setOrigin] = React.useState<[number, number] | null>(null);
// trigger="manual": follow the prop. Covering again is instant; revealing animates.
const [prevRevealed, setPrevRevealed] = React.useState(revealed);
if (trigger === "manual" && revealed !== prevRevealed) {
setPrevRevealed(revealed);
setOpen(!!revealed);
if (!revealed) setSettled(false);
}
const { r, c } = gridSize(rows, cols);
const total = Math.max(200, Math.round(duration));
const tileMs = Math.max(160, Math.round(total * 0.42));
const spread = total - tileMs;
const order = waveOrder(pattern, r, c, pattern === "pointer" ? origin : null);
const finishMs = reduceMotion ? FADE_MS : total;
/** Starts the wave; `at` is the origin as fractions of the box (pattern="pointer"). */
const reveal = (at: [number, number] | null) => {
setOrigin(at ?? pointerRef.current);
setOpen(true);
};
// trigger="view": reveal once a third of the box is on screen.
React.useEffect(() => {
const root = rootRef.current;
if (trigger !== "view" || open || !root) return;
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
setOrigin(pointerRef.current);
setOpen(true);
},
{ threshold: 0.35 },
);
io.observe(root);
return () => io.disconnect();
}, [trigger, open]);
// When the last tile is gone: drop the cover, make the content interactive, hand focus over.
const finish = React.useEffectEvent(() => {
setSettled(true);
onReveal?.();
});
React.useEffect(() => {
if (!open || settled) return;
const id = window.setTimeout(finish, finishMs + 40);
return () => window.clearTimeout(id);
}, [open, settled, finishMs]);
React.useEffect(() => {
if (!settled || !refocusRef.current) return;
refocusRef.current = false;
contentRef.current?.focus({ preventScroll: true });
}, [settled]);
const toFraction = (clientX: number, clientY: number): [number, number] | null => {
const rect = rootRef.current?.getBoundingClientRect();
if (!rect || !rect.width || !rect.height) return null;
return [Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)), Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))];
};
const tileClass = (i: number) => {
const row = Math.floor(i / c);
const col = i % c;
if (tone === "accent") return "bg-[var(--tr-accent)] shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.12)]";
if (tone === "mixed") {
if (hash(i * 7 + 3) < 0.14) return "bg-[var(--tr-accent)] shadow-[inset_0_0_0_0.5px_rgb(0_0_0/0.12)]";
return (row + col) % 2
? "bg-zinc-800 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.06)] dark:bg-zinc-700"
: "bg-zinc-900 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.06)] dark:bg-zinc-800";
}
return "bg-zinc-900 shadow-[inset_0_0_0_0.5px_rgb(255_255_255/0.07)] dark:bg-zinc-800";
};
const tiles = order.map((t, i) => {
const delay = Math.round(t * spread);
// Flipping tiles stay opaque until they are nearly edge-on; the others fade as they go.
const fadeMs = effect === "flip" ? Math.round(tileMs * 0.3) : tileMs;
const fadeDelay = effect === "flip" ? delay + Math.round(tileMs * 0.7) : delay;
const tileStyle: React.CSSProperties = reduceMotion
? {}
: {
transform: open ? GONE[effect] : "none",
opacity: open ? 0 : 1,
transitionProperty: "transform, opacity",
transitionDuration: `${tileMs}ms, ${fadeMs}ms`,
transitionDelay: `${delay}ms, ${fadeDelay}ms`,
transitionTimingFunction: `${EASE_OUT}, ${EASE_FADE}`,
};
// Tiles overlap their neighbours by 1px, so sub-pixel seams never let the content peek through.
return <span key={i} className={`-m-px block will-change-transform ${tileClass(i)}`} style={tileStyle} />;
});
const coverStyle: React.CSSProperties = {
gridTemplateColumns: `repeat(${c}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${r}, minmax(0, 1fr))`,
// Reduced motion: no tile movement, the whole cover cross-fades.
...(reduceMotion ? { opacity: open ? 0 : 1, transition: `opacity ${FADE_MS}ms ease` } : null),
};
const labelPill = (
<span
aria-hidden="true"
className={`pointer-events-none absolute left-1/2 top-1/2 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-white/15 bg-zinc-950/80 px-3.5 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-white shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] transition-opacity duration-200 ${
open ? "opacity-0" : "opacity-100"
}`}
>
<svg viewBox="0 0 16 16" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 2h5v5H2zM9 9h5v5H9z" />
<path d="M9 2h5v5H9zM2 9h5v5H2z" opacity="0.4" />
</svg>
{label}
</span>
);
return (
<div
ref={rootRef}
className={`relative isolate overflow-hidden ${className}`}
style={{ ["--tr-accent" as string]: accent, ...style }}
onPointerMove={(e) => {
pointerRef.current = toFraction(e.clientX, e.clientY);
onPointerMove?.(e);
}}
{...props}
>
<div ref={contentRef} inert={!settled} tabIndex={-1} className="h-full w-full outline-hidden">
{children}
</div>
{!settled &&
(trigger === "click" ? (
<button
ref={coverRef}
type="button"
aria-label={label}
disabled={open}
onClick={(e) => {
refocusRef.current = true;
// Keyboard activation has no pointer position: start from the centre.
reveal(e.detail === 0 ? [0.5, 0.5] : toFraction(e.clientX, e.clientY));
}}
className="group absolute inset-0 grid cursor-pointer outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)] disabled:cursor-default"
style={coverStyle}
>
{tiles}
{labelPill}
</button>
) : (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 grid" style={coverStyle}>
{tiles}
</div>
))}
</div>
);
}