Glyph Shatter Reveal
Reveals & Transitions

Glyph Shatter Reveal

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.

Install

npx shadcn@latest add @paragon/glyph-shatter-reveal

glyph-shatter-reveal.tsx

"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>
  );
}