Shatter Reveal
A cover breaks along a spreading crack and the pieces fall away from what was behind it.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Shatter Reveal
*
* A cover breaks and the pieces fall away, leaving whatever sits behind
* it.
*
* The technique: the shards are a true tiling, not a scatter of quads.
* The rectangle is split recursively along random chords, so every edge
* is shared with exactly one neighbour — at rest the cover is one solid
* panel with no gaps and no seams, which is the only way the first frame
* can look like an unbroken surface. Scattered rectangles give
* themselves away before anything has moved.
*
* A crack front runs outward from the impact point a beat ahead of the
* pieces, so the break propagates instead of everything letting go at
* once.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ShatterRevealProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many shards the surface breaks into. */
count?: number;
/** The cover's tone — it stands in for glass, not for a UI surface. */
color?: string;
/** Impact point across the box, 0–1. */
originX?: number;
/** Impact point down the box, 0–1. */
originY?: number;
/** Fires once the last shard has left the frame. */
onRevealed?: () => void;
};
type VariantConfig = {
/** How fast the crack front travels outward, in px per second. */
crackSpeed: number;
/** Seconds a shard stays in place after the crack reaches it. */
lead: number;
/** Downward acceleration in px per second squared. */
gravity: number;
/** Outward push away from the impact point, in px per second. */
burst: number;
/** Turns per second, before per-shard variation. */
spin: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The cover gives way quietly: a slow crack, almost no sideways throw.
subtle: { crackSpeed: 620, lead: 0.3, gravity: 780, burst: 26, spin: 0.22 },
// Reads as breaking glass without becoming an explosion.
default: { crackSpeed: 900, lead: 0.2, gravity: 1150, burst: 52, spin: 0.4 },
// A harder hit: the crack outruns the eye and the pieces scatter wide.
playful: { crackSpeed: 1400, lead: 0.12, gravity: 1500, burst: 96, spin: 0.7 },
};
type Point = { x: number; y: number };
type Shard = {
/** Vertices relative to the shard's own centroid, so it spins in place. */
points: Point[];
cx: number;
cy: number;
/** Seconds until the crack front reaches this shard. */
crack: number;
/** Seconds until it lets go of its neighbours. */
release: number;
vx: number;
vy: number;
spin: number;
/** Seconds from release to fully faded. */
life: number;
/** Per-shard fill weight — flat glass still catches light unevenly. */
tone: number;
};
/** Signed area doubled; only ever compared, never used as a real area. */
function polygonArea(polygon: Point[]) {
let total = 0;
for (let index = 0; index < polygon.length; index++) {
const a = polygon[index];
const b = polygon[(index + 1) % polygon.length];
total += a.x * b.y - b.x * a.y;
}
return Math.abs(total) / 2;
}
function polygonCentroid(polygon: Point[]): Point {
let x = 0;
let y = 0;
for (const point of polygon) {
x += point.x;
y += point.y;
}
return { x: x / polygon.length, y: y / polygon.length };
}
/**
* Cut a convex polygon with the line through (px, py) normal to (nx, ny).
* Both halves come back convex, and the cut edge is byte-identical in
* each — that shared edge is what keeps the tiling gapless.
*/
function splitPolygon(
polygon: Point[],
px: number,
py: number,
nx: number,
ny: number
): [Point[], Point[]] {
const front: Point[] = [];
const back: Point[] = [];
for (let index = 0; index < polygon.length; index++) {
const a = polygon[index];
const b = polygon[(index + 1) % polygon.length];
const da = (a.x - px) * nx + (a.y - py) * ny;
const db = (b.x - px) * nx + (b.y - py) * ny;
if (da >= 0) front.push(a);
else back.push(a);
if ((da > 0 && db < 0) || (da < 0 && db > 0)) {
const t = da / (da - db);
const cut = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
front.push(cut);
back.push({ x: cut.x, y: cut.y });
}
}
return [front, back];
}
export default function ShatterReveal({
variant = "default",
count = 34,
color = "#8E9AAE",
originX = 0.5,
originY = 0.42,
onRevealed,
}: ShatterRevealProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Read through a ref so an inline arrow from the parent can't restart
// the break on every render. Assigned in an effect, never during
// render, so React's ref rules stay satisfied.
const revealedRef = useRef(onRevealed);
useEffect(() => {
revealedRef.current = onRevealed;
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const build = (): Shard[] => {
const pieces: Point[][] = [
[
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: width, y: height },
{ x: 0, y: height },
],
];
let guard = 0;
while (pieces.length < count && guard++ < count * 24) {
// Tournament of three. Always splitting the single largest piece
// makes every shard the same size; the largest of three random
// candidates keeps sizes varied without producing slivers.
let index = Math.floor(Math.random() * pieces.length);
for (let attempt = 0; attempt < 2; attempt++) {
const other = Math.floor(Math.random() * pieces.length);
if (polygonArea(pieces[other]) > polygonArea(pieces[index])) index = other;
}
const polygon = pieces[index];
const angle = Math.random() * Math.PI;
const nx = Math.cos(angle);
const ny = Math.sin(angle);
// Place the cut inside the piece's own extent along the cut
// direction, not at a fixed distance from its centroid: a fixed
// offset grazes the corner of a long thin piece and shaves off a
// sliver. Projecting first means "a third of the way across"
// means the same thing whatever shape the piece has.
let low = Infinity;
let high = -Infinity;
for (const point of polygon) {
const projection = point.x * nx + point.y * ny;
if (projection < low) low = projection;
if (projection > high) high = projection;
}
const at = low + (high - low) * random(0.34, 0.66);
const [front, back] = splitPolygon(polygon, at * nx, at * ny, nx, ny);
if (front.length < 3 || back.length < 3) continue;
// A shard smaller than a sixth of its parent is grit, not glass.
const smaller = Math.min(polygonArea(front), polygonArea(back));
if (smaller < polygonArea(polygon) * 0.16) continue;
pieces[index] = front;
pieces.push(back);
}
const impactX = width * originX;
const impactY = height * originY;
return pieces.map((polygon) => {
const centre = polygonCentroid(polygon);
const dx = centre.x - impactX;
const dy = centre.y - impactY;
const distance = Math.max(Math.hypot(dx, dy), 1);
const crack = distance / config.crackSpeed;
return {
points: polygon.map((point) => ({ x: point.x - centre.x, y: point.y - centre.y })),
cx: centre.x,
cy: centre.y,
crack,
release: crack + config.lead * random(0.7, 1.3),
vx: (dx / distance) * config.burst * random(0.5, 1.4),
vy: (dy / distance) * config.burst * 0.45 - config.burst * random(0.1, 0.5),
spin: random(-config.spin, config.spin) * Math.PI * 2,
life: random(0.85, 1.35),
tone: random(0.74, 1),
};
});
};
let shards = build();
const drawShardPath = (shard: Shard, x: number, y: number, rotation: number) => {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
context.beginPath();
for (let index = 0; index < shard.points.length; index++) {
const point = shard.points[index];
const px = x + point.x * cos - point.y * sin;
const py = y + point.x * sin + point.y * cos;
if (index === 0) context.moveTo(px, py);
else context.lineTo(px, py);
}
context.closePath();
};
const render = (elapsed: number) => {
context.clearRect(0, 0, width, height);
// Everything still in place is filled as one path. Filling shards
// individually leaves hairline antialiasing seams between them,
// and a cover with visible seams is not a cover.
context.beginPath();
let intact = 0;
for (const shard of shards) {
if (elapsed >= shard.release) continue;
intact++;
for (let index = 0; index < shard.points.length; index++) {
const point = shard.points[index];
const px = shard.cx + point.x;
const py = shard.cy + point.y;
if (index === 0) context.moveTo(px, py);
else context.lineTo(px, py);
}
context.closePath();
}
if (intact > 0) {
context.globalAlpha = 0.94;
context.fillStyle = color;
context.fill();
}
// Cracked but not yet fallen: hairlines only, so the break is
// visible travelling across a surface that is still whole.
context.strokeStyle = "#FFFFFF";
context.lineWidth = 0.9;
for (const shard of shards) {
if (elapsed < shard.crack || elapsed >= shard.release) continue;
context.globalAlpha = 0.4;
drawShardPath(shard, shard.cx, shard.cy, 0);
context.stroke();
}
let live = 0;
for (const shard of shards) {
const since = elapsed - shard.release;
if (since < 0) continue;
const fade = 1 - since / shard.life;
if (fade <= 0) continue;
live++;
const x = shard.cx + shard.vx * since;
const y = shard.cy + shard.vy * since + 0.5 * config.gravity * since * since;
const rotation = shard.spin * since;
drawShardPath(shard, x, y, rotation);
context.globalAlpha = fade * shard.tone * 0.94;
context.fillStyle = color;
context.fill();
context.globalAlpha = fade * 0.32;
context.strokeStyle = "#FFFFFF";
context.stroke();
}
context.globalAlpha = 1;
return intact + live;
};
// Reduced motion: the cover cracked but still standing, dropped to a
// weight that reads through to the content behind. The reveal is
// stated rather than performed — a blank canvas would say nothing.
if (reduced) {
const still = () => {
context.clearRect(0, 0, width, height);
context.fillStyle = color;
context.globalAlpha = 0.15;
context.fillRect(0, 0, width, height);
context.strokeStyle = color;
context.lineWidth = 1;
context.globalAlpha = 0.34;
for (const shard of shards) {
drawShardPath(shard, shard.cx, shard.cy, 0);
context.stroke();
}
context.globalAlpha = 1;
};
still();
const onResizeStill = () => {
resize();
shards = build();
still();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
let announced = false;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
const remaining = render(elapsed);
if (remaining === 0) {
if (!announced) {
announced = true;
revealedRef.current?.();
}
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
// Rebuild the tiling for the new box but keep the clock: a resize
// mid-break should not restart the break.
shards = build();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color, originX, originY]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
For the moment a result stops being hidden — an unlocked report, a revealed score, a purchase that has finished processing. The shards are a true tiling rather than a scatter of rectangles: the cover is split recursively along random chords, so every edge is shared with exactly one neighbour and the first frame is a solid panel with no seams and no gaps to give the trick away. A crack front travels outward from the impact point a fraction of a second ahead of the pieces, so the break propagates across the surface instead of every shard letting go at the same instant.