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.
npx shadcn@latest add @paragon/tile-flip-reveal"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. The flip delay per tile is computed from its
* (row, col) position for the chosen order — never eyeballed. Runs once on
* scroll-into-view (`useInView`, once) or on hover/click; 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,
...props
}: TileFlipRevealProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.35 });
const reduce = useReducedMotion();
const [hovered, setHovered] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
const open =
reduce ||
(trigger === "view" && inView) ||
(trigger === "hover" && hovered) ||
(trigger === "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]);
return (
<div
ref={ref}
data-slot="tile-flip-reveal"
className={cn("relative overflow-hidden", className)}
onMouseEnter={trigger === "hover" ? () => setHovered(true) : undefined}
onClick={trigger === "click" ? () => setClicked(true) : undefined}
{...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",
}}
>
{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={{
duration: reduce ? 0 : duration,
delay: reduce ? 0 : rank * stagger,
ease: [0.22, 1, 0.36, 1],
}}
>
{/* Front (opaque cover) — swings away, hiding its back face. */}
<div
className="absolute inset-0"
style={{
background: color,
backfaceVisibility: "hidden",
boxShadow:
"inset 0 0 0 1px color-mix(in oklch, var(--color-background) 45%, transparent)",
}}
/>
</motion.div>
</div>
))}
</div>
)}
</div>
);
}