"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Eye, EyeOff, MousePointer2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MagneticGridRevealProps {
/** Content hidden under the tiles. */
children?: React.ReactNode;
/** Target tile size in px; the grid adapts to the container. */
tileSize?: number;
/** Flip radius around the cursor, in tiles. */
radius?: number;
/** ms a tile stays flipped after the cursor leaves. */
linger?: number;
height?: number;
coverLabel?: string;
defaultRevealed?: boolean;
onRevealChange?: (revealed: boolean) => void;
className?: string;
}
interface TileState {
flipped: boolean;
axis: "x" | "y";
dir: 1 | -1;
tilt: [number, number];
}
export function MagneticGridReveal({
children,
tileSize = 58,
radius = 1.7,
linger = 650,
height = 420,
coverLabel = "Hover to peek · click to reveal",
defaultRevealed = false,
onRevealChange,
className,
}: MagneticGridRevealProps) {
const reduce = useReducedMotion() ?? false;
const rootRef = React.useRef<HTMLDivElement>(null);
const tileRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const [dims, setDims] = React.useState({ cols: 12, rows: 7 });
const [revealed, setRevealed] = React.useState(defaultRevealed);
const [peeking, setPeeking] = React.useState(false);
const pointer = React.useRef<{ c: number; r: number } | null>(null);
const lastHit = React.useRef<number[]>([]);
const tiles = React.useRef<TileState[]>([]);
const raf = React.useRef(0);
const revealedRef = React.useRef(revealed);
const { cols, rows } = dims;
const count = cols * rows;
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => {
const c = Math.max(4, Math.round(e.contentRect.width / tileSize));
const r = Math.max(3, Math.round(e.contentRect.height / tileSize));
setDims((d) => (d.cols === c && d.rows === r ? d : { cols: c, rows: r }));
});
ro.observe(el);
return () => ro.disconnect();
}, [tileSize]);
const apply = React.useCallback(
(i: number, s: TileState, delay = 0) => {
const el = tileRefs.current[i];
if (!el) return;
el.style.transitionDelay = `${delay}ms`;
const flip = s.flipped ? 180 * s.dir : 0;
const rx = s.axis === "x" ? flip : 0;
const ry = s.axis === "y" ? flip : 0;
el.style.transform = `rotateX(${rx + (s.flipped ? 0 : s.tilt[0])}deg) rotateY(${ry + (s.flipped ? 0 : s.tilt[1])}deg)`;
},
[],
);
// Reset tile state when the grid changes.
React.useEffect(() => {
tiles.current = Array.from({ length: count }, () => ({ flipped: revealedRef.current, axis: "y", dir: 1, tilt: [0, 0] }));
lastHit.current = Array.from({ length: count }, () => -Infinity);
tiles.current.forEach((s, i) => apply(i, s));
}, [count, apply]);
const tickRef = React.useRef<() => void>(() => {});
const tick = React.useCallback(() => {
const now = performance.now();
const p = pointer.current;
let busy = !!p;
for (let i = 0; i < count; i++) {
const s = tiles.current[i];
if (!s) continue;
const c = i % cols;
const r = Math.floor(i / cols);
let tilt: [number, number] = [0, 0];
if (p) {
const dx = c + 0.5 - p.c;
const dy = r + 0.5 - p.r;
const d = Math.hypot(dx, dy);
if (d < radius) {
if (!s.flipped) {
s.axis = Math.abs(dx) > Math.abs(dy) ? "y" : "x";
s.dir = (s.axis === "y" ? dx : -dy) >= 0 ? 1 : -1;
}
lastHit.current[i] = now;
} else if (d < radius * 2.4 && !reduce) {
const f = (1 - d / (radius * 2.4)) * 22;
tilt = [(-dy / d) * f, (dx / d) * f];
}
}
const flipped = now - lastHit.current[i] < linger;
if (flipped) busy = true;
const changed = flipped !== s.flipped || Math.abs(tilt[0] - s.tilt[0]) > 0.5 || Math.abs(tilt[1] - s.tilt[1]) > 0.5;
if (changed) {
s.flipped = flipped;
s.tilt = tilt;
apply(i, s);
}
}
if (busy) raf.current = requestAnimationFrame(() => tickRef.current());
else {
raf.current = 0;
setPeeking(false);
}
}, [count, cols, radius, linger, reduce, apply]);
React.useLayoutEffect(() => {
tickRef.current = tick;
}, [tick]);
React.useEffect(() => () => cancelAnimationFrame(raf.current), []);
const onMove = (e: React.PointerEvent) => {
if (revealedRef.current) return;
const r = e.currentTarget.getBoundingClientRect();
pointer.current = { c: ((e.clientX - r.left) / r.width) * cols, r: ((e.clientY - r.top) / r.height) * rows };
setPeeking(true);
if (!raf.current) raf.current = requestAnimationFrame(() => tickRef.current());
};
const ripple = (origin?: { c: number; r: number }) => {
const next = !revealedRef.current;
revealedRef.current = next;
setRevealed(next);
onRevealChange?.(next);
pointer.current = null;
cancelAnimationFrame(raf.current);
raf.current = 0;
const o = origin ?? { c: cols / 2, r: rows / 2 };
for (let i = 0; i < count; i++) {
const s = tiles.current[i];
if (!s) continue;
const c = i % cols;
const r = Math.floor(i / cols);
const dx = c + 0.5 - o.c;
const dy = r + 0.5 - o.r;
s.axis = Math.abs(dx) > Math.abs(dy) ? "y" : "x";
s.dir = (s.axis === "y" ? dx : -dy) >= 0 ? 1 : -1;
s.flipped = next;
s.tilt = [0, 0];
lastHit.current[i] = -Infinity;
apply(i, s, reduce ? 0 : Math.hypot(dx, dy) * 45);
}
// Clear the stagger so hover stays snappy afterwards.
window.setTimeout(() => tileRefs.current.forEach((el) => el && (el.style.transitionDelay = "0ms")), 1400);
};
return (
<div
ref={rootRef}
className={cn("relative w-full overflow-hidden rounded-3xl border bg-background", className)}
style={{ height }}
>
<div className="absolute inset-0" inert={!revealed}>
{children ?? <DefaultContent />}
</div>
<div
aria-hidden
className={cn("absolute inset-0 grid [perspective:900px]", revealed ? "pointer-events-none" : "cursor-pointer")}
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))` }}
onPointerMove={onMove}
onPointerLeave={() => (pointer.current = null)}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
ripple({ c: ((e.clientX - r.left) / r.width) * cols, r: ((e.clientY - r.top) / r.height) * rows });
}}
>
{Array.from({ length: count }, (_, i) => {
const c = i % cols;
const r = Math.floor(i / cols);
return (
<div
key={`${cols}-${i}`}
ref={(el) => {
tileRefs.current[i] = el;
}}
className="relative [transform-style:preserve-3d] will-change-transform"
style={{ transition: reduce ? "transform 0.2s" : "transform 0.65s cubic-bezier(0.2, 0.9, 0.25, 1.15)" }}
>
<div
className="absolute -inset-[0.5px] border-[0.5px] border-foreground/[0.06] bg-muted [backface-visibility:hidden] dark:border-white/[0.05]"
style={{
backgroundImage:
"radial-gradient(120% 90% at 20% 10%, color-mix(in oklch, var(--color-primary) 22%, transparent), transparent 60%), radial-gradient(90% 90% at 90% 100%, color-mix(in oklch, #ec4899 16%, transparent), transparent 60%)",
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: `${cols > 1 ? (c / (cols - 1)) * 100 : 0}% ${rows > 1 ? (r / (rows - 1)) * 100 : 0}%`,
}}
>
<span className="absolute top-1/2 left-1/2 size-1 -translate-1/2 rounded-full bg-foreground/15" />
</div>
</div>
);
})}
</div>
{/* Hint */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-500",
revealed || peeking ? "opacity-0" : "opacity-100",
)}
>
<span className="inline-flex items-center gap-2 rounded-full border bg-background/80 px-4 py-2 text-sm font-medium shadow-lg backdrop-blur">
<MousePointer2 className="size-4 text-primary" /> {coverLabel}
</span>
</div>
<button
type="button"
onClick={() => ripple()}
aria-pressed={revealed}
className="absolute top-3 right-3 z-10 inline-flex h-8 items-center gap-1.5 rounded-full border bg-background/85 px-3 text-xs font-medium shadow-sm backdrop-blur transition hover:bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{revealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{revealed ? "Cover" : "Reveal"}
</button>
</div>
);
}
function DefaultContent() {
return (
<div className="relative flex size-full flex-col items-center justify-center overflow-hidden bg-[radial-gradient(90%_70%_at_50%_0%,#4f46e5_0%,#1e1b4b_55%,#0b0a1a_100%)] px-6 text-center text-white">
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.06)_95%),linear-gradient(90deg,transparent_95%,rgba(255,255,255,0.06)_95%)] bg-[size:28px_28px]" />
<span className="relative rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-semibold tracking-[0.2em] uppercase">Now live</span>
<h3 className="relative mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">Northwind 3.0</h3>
<p className="relative mt-3 max-w-md text-sm text-white/75 sm:text-base">Realtime sync, offline-first storage and a brand new plugin runtime.</p>
<dl className="relative mt-6 grid grid-cols-3 gap-6 text-left sm:gap-10">
{[
["4.2×", "faster sync"],
["120", "new plugins"],
["0 ms", "cold starts"],
].map(([v, l]) => (
<div key={l}>
<dt className="sr-only">{l}</dt>
<dd className="text-xl font-semibold sm:text-2xl">{v}</dd>
<dd className="text-xs text-white/60">{l}</dd>
</div>
))}
</dl>
<button className="relative mt-7 rounded-full bg-white px-5 py-2.5 text-sm font-medium text-indigo-950 transition hover:bg-white/90 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-950 focus-visible:outline-none">
Read the release notes
</button>
</div>
);
}