The surface dices into a grid of shards that fly in scattered — offset, rotated, scaled — and settle on a zero-bounce spring to compose the content shard by shard.
npx shadcn@latest add @paragon/glyph-shatter-reveal"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/** Small deterministic PRNG so scatter vectors are stable across renders. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
}
return h;
}
export type ShatterOrigin = "center" | "edges" | "scatter";
export interface GlyphShatterRevealProps extends React.ComponentProps<"div"> {
/** Grid rows. */
rows?: number;
/** Grid columns. */
cols?: number;
/** Where the shards fly in from. */
from?: ShatterOrigin;
/** How far shards start from home, in px (scaled by container for `scatter`). */
spread?: number;
/** Per-tile stagger, in seconds. */
stagger?: number;
/** Settle duration per tile, in seconds. */
duration?: number;
/** Seed for the deterministic scatter. */
seed?: number;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
children: React.ReactNode;
}
/**
* GlyphShatterReveal — the surface is diced into a grid of shards that start
* scattered (offset, rotated, scaled, faded) and fly home on a zero-bounce
* spring, composing the content shard by shard. Each shard is a clipped copy of
* the same children — a full-size render translated to its cell origin inside an
* `overflow-hidden` window — so any content assembles with no image asset and no
* per-shard markup from the caller. Shards fly in from the center, the edges, or
* a seeded scatter, with a per-tile stagger.
*
* Shards animate `transform` + `opacity` only; their slices are exact
* continuations of one another, so the assembled grid is seamless. As the last
* shards land, the shard layer cross-fades out and the real content underneath
* (which carries accessibility) becomes the resting image — so the frame you
* end on is the DOM itself, pixel-perfect. Runs once on scroll-into-view
* (`useInView`, once) or on hover/click (`hover` falls back to
* `view` on touch where hover never fires; `click` is keyboard-operable —
* Enter/Space). Under `prefers-reduced-motion` the content is shown composed at
* once with no scatter. Deterministic — no Math.random in render.
*/
export function GlyphShatterReveal({
rows = 5,
cols = 6,
from = "scatter",
spread = 120,
stagger = 0.022,
duration = 0.62,
seed,
trigger = "view",
className,
children,
onClick,
onKeyDown,
onPointerEnter,
...props
}: GlyphShatterRevealProps) {
const reactId = React.useId();
const resolvedSeed = seed ?? hashString(reactId);
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.35 });
const reduce = useReducedMotion();
const [fine, setFine] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
const open =
reduce ||
(effectiveTrigger === "view" && inView) ||
(effectiveTrigger === "hover" && hovered) ||
(effectiveTrigger === "click" && clicked);
const r = Math.max(1, Math.round(rows));
const c = Math.max(1, Math.round(cols));
// Per-shard scatter vector + rank, all deterministic. Fractions are in the
// 0..1 cell space; the runtime multiplies by container size for `scatter`.
const shards = React.useMemo(() => {
const rand = mulberry32(resolvedSeed);
const mx = (c - 1) / 2;
const my = (r - 1) / 2;
const out: Array<{
x: number;
y: number;
rot: number;
scale: number;
rank: number;
}> = [];
for (let y = 0; y < r; y++) {
for (let x = 0; x < c; x++) {
const fx = c <= 1 ? 0 : x / (c - 1);
const fy = r <= 1 ? 0 : y / (r - 1);
// Direction of the scatter offset.
let ux: number;
let uy: number;
if (from === "center") {
ux = mx > 0 ? (x - mx) / mx : (rand() - 0.5) * 2;
uy = my > 0 ? (y - my) / my : (rand() - 0.5) * 2;
} else if (from === "edges") {
ux = mx > 0 ? -(x - mx) / mx : 0;
uy = my > 0 ? -(y - my) / my : 0;
} else {
const a = rand() * Math.PI * 2;
ux = Math.cos(a);
uy = Math.sin(a);
}
const mag = 0.6 + rand() * 0.7;
// Rank: center-out, edges-in, or seeded for scatter.
let rank: number;
if (from === "center") rank = Math.hypot(fx - 0.5, fy - 0.5);
else if (from === "edges") rank = 1 - Math.hypot(fx - 0.5, fy - 0.5);
else rank = rand();
out.push({
x: ux * mag,
y: uy * mag,
rot: (rand() - 0.5) * 34,
scale: 0.72 + rand() * 0.14,
rank,
});
}
}
// Normalize ranks to a stable 0..1 so stagger is independent of grid size.
const ranks = out.map((s) => s.rank);
const lo = Math.min(...ranks);
const hi = Math.max(...ranks);
const span = hi - lo || 1;
for (const s of out) s.rank = (s.rank - lo) / span;
return out;
}, [r, c, from, resolvedSeed]);
const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;
return (
<div
ref={ref}
data-slot="glyph-shatter-reveal"
className={cn("relative overflow-hidden", className)}
role={awaitingClick ? "button" : undefined}
tabIndex={awaitingClick ? 0 : undefined}
aria-label={awaitingClick ? "Reveal content" : undefined}
onPointerEnter={(e) => {
onPointerEnter?.(e);
if (effectiveTrigger === "hover") setHovered(true);
}}
onClick={(e) => {
onClick?.(e);
if (effectiveTrigger === "click") setClicked(true);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (awaitingClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setClicked(true);
}
}}
{...props}
>
{/* The real content: accessible, and the resting image the shards land on.
While shards fly it is hidden so nothing double-renders; once settled
(or under reduced motion) it fades in and the shard layer clears. */}
<div
style={{
opacity: reduce || open ? 1 : 0,
transition: reduce
? undefined
: `opacity 0.2s var(--ease-out) ${open ? duration * 0.55 : 0}s`,
}}
>
{children}
</div>
{!reduce && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 grid"
style={{
gridTemplateColumns: `repeat(${c}, 1fr)`,
gridTemplateRows: `repeat(${r}, 1fr)`,
// The shard copies fade out just as the real content fades in, so
// the hand-off is invisible and the resting frame is the DOM itself.
opacity: open ? 0 : 1,
transition: `opacity 0.18s var(--ease-out) ${open ? duration * 0.6 : 0}s`,
}}
>
{shards.map((s, i) => {
const gx = c <= 1 ? 0 : (i % c) / (c - 1);
const gy = r <= 1 ? 0 : Math.floor(i / c) / (r - 1);
// Total stagger window scales with the grid but stays bounded, so a
// dense grid still assembles briskly.
const settle = s.rank * Math.min(0.5, r * c * stagger * 0.5);
const offX = s.x * spread;
const offY = s.y * spread;
return (
<div key={i} className="relative overflow-hidden">
<motion.div
className="absolute inset-0"
style={{ willChange: "transform" }}
initial={{
x: offX,
y: offY,
rotate: s.rot,
scale: s.scale,
opacity: 0,
}}
animate={{
x: open ? 0 : offX,
y: open ? 0 : offY,
rotate: open ? 0 : s.rot,
scale: open ? 1 : s.scale,
opacity: open ? 1 : 0,
}}
transition={{
default: {
type: "spring",
duration,
bounce: 0,
delay: reduce ? 0 : settle,
},
opacity: {
duration: reduce ? 0 : duration * 0.5,
delay: reduce ? 0 : settle,
ease: [0.22, 1, 0.36, 1],
},
}}
>
{/* A full-size copy of the content, shifted so this window
frames exactly this shard's slice. Percentages map the
cell's grid position onto the full surface. */}
<div
className="absolute"
style={{
width: `${c * 100}%`,
height: `${r * 100}%`,
left: `${-gx * (c - 1) * 100}%`,
top: `${-gy * (r - 1) * 100}%`,
}}
>
{children}
</div>
</motion.div>
</div>
);
})}
</div>
)}
</div>
);
}