Tile Flip Reveal
Reveals & Transitions

Tile Flip Reveal

A grid of tiles 3D-flips in a wave or diagonal order to reveal the content on their back face, assembling the image tile by tile.

Install

npx shadcn@latest add @paragon/tile-flip-reveal

tile-flip-reveal.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type TileFlipOrder = "diagonal" | "row" | "column" | "wave" | "random";

/** Small deterministic PRNG so the "random" order is 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;
  };
}

export interface TileFlipRevealProps extends React.ComponentProps<"div"> {
  /** Grid rows. */
  rows?: number;
  /** Grid columns. */
  cols?: number;
  /** Order the tiles flip in. */
  order?: TileFlipOrder;
  /** Cover-face color. Defaults to the card token. */
  color?: string;
  /** Per-step stagger, in seconds. */
  stagger?: number;
  /** Flip duration per tile, in seconds. */
  duration?: number;
  /** How the reveal is triggered. */
  trigger?: "view" | "hover" | "click";
  /** Seed for the "random" order. */
  seed?: number;
  children: React.ReactNode;
}

/**
 * TileFlipReveal — a grid of tiles 3D-flips (preserve-3d) in a wave / diagonal
 * order to reveal the content on their back face. Each tile shows a solid cover
 * on its front; as it rotates 180° on Y, the opaque front swings away and the
 * transparent back lets the content beneath show through, so the image
 * assembles tile by tile.
 *
 * Tiles animate `transform` only (a zero-bounce spring drives the flip). The
 * flip delay per tile is computed from its (row, col) position for the chosen
 * order — never eyeballed — and each cover face bleeds ~0.6px and overscales a
 * hair so composited-layer rounding never shows a hairline of content between
 * closed tiles nor a gap to a neighbor mid-flip. 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` all tiles are already flipped away and the content
 * shows immediately. Content is real DOM under an `aria-hidden` tile layer.
 */
export function TileFlipReveal({
  rows = 4,
  cols = 5,
  order = "diagonal",
  color = "var(--color-card)",
  stagger = 0.05,
  duration = 0.5,
  trigger = "view",
  seed = 1,
  className,
  children,
  onClick,
  onKeyDown,
  onPointerEnter,
  ...props
}: TileFlipRevealProps) {
  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);
  }, []);

  // Hover can never fire on touch — fall back to the in-view reveal there.
  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));

  // Deterministic per-tile flip order index, normalized so max delay is stable.
  const delays = React.useMemo(() => {
    const rand = mulberry32(seed);
    const randVals = Array.from({ length: r * c }, () => rand());
    const out: number[] = [];
    for (let y = 0; y < r; y++) {
      for (let x = 0; x < c; x++) {
        let rank: number;
        switch (order) {
          case "row":
            rank = y * c + x;
            break;
          case "column":
            rank = x * r + y;
            break;
          case "wave":
            rank = x + Math.abs(y - (r - 1) / 2);
            break;
          case "random":
            rank = randVals[y * c + x] * (r + c);
            break;
          case "diagonal":
          default:
            rank = x + y;
            break;
        }
        out.push(rank);
      }
    }
    return out;
  }, [r, c, order, seed]);

  const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;

  return (
    <div
      ref={ref}
      data-slot="tile-flip-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}
    >
      {children}

      {!reduce && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 grid"
          style={{
            gridTemplateColumns: `repeat(${c}, 1fr)`,
            gridTemplateRows: `repeat(${r}, 1fr)`,
            perspective: "1400px",
            perspectiveOrigin: "center",
          }}
        >
          {delays.map((rank, i) => (
            <div key={i} style={{ transformStyle: "preserve-3d" }}>
              <motion.div
                className="size-full"
                style={{
                  transformStyle: "preserve-3d",
                  transformOrigin: "center",
                }}
                initial={{ rotateY: 0 }}
                animate={{ rotateY: open ? 180 : 0 }}
                transition={{
                  // Zero-bounce spring settles each tile with real deceleration
                  // rather than a linear-feeling tween.
                  type: "spring",
                  duration: reduce ? 0 : duration,
                  bounce: 0,
                  delay: reduce ? 0 : rank * stagger,
                }}
              >
                {/* Front (opaque cover) — swings away, hiding its back face.
                    Scaled a hair past its cell (~1px each side) so the
                    foreshortened plane never opens a gap to a neighbor tile
                    mid-flip; a soft directional shade gives the surface depth. */}
                <div
                  className="absolute inset-0"
                  style={{
                    background: color,
                    backgroundImage: `linear-gradient(135deg, color-mix(in oklch, #fff 9%, transparent), transparent 45%, color-mix(in oklch, #000 12%, transparent))`,
                    backfaceVisibility: "hidden",
                    transform: "scale(1.012)",
                    boxShadow: `0 0 0 0.6px ${color}, inset 0 0 0 1px color-mix(in oklch, var(--color-background) 45%, transparent)`,
                  }}
                />
              </motion.div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}