← All particles

Shatter Reveal

A cover breaks along a spreading crack and the pieces fall away from what was behind it.

revealenergeticpremium34 particles · light · canvas-2d · automatic · finite
Variant

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.

399 lines · react only
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.

Unlocking a resultReveal after processingScore revealPaywall lifting

Related effects