Skip to main content

ns-ui

Crack Polygon Order

A decorative panel fill that tiles itself the way drying mud actually cracks: widely-spaced primary cracks grow first, then two more generations nucleate inside the largest surviving cells after a 900ms stress-rebuild pause each, every new crack tip T-junctioning into an existing one at ~90 degrees instead of crossing it, until the mature tiling holds, rewets to blank, and a new unrelated cycle begins.

Usage

registry/core/crack-polygon-order/demo.tsx
"use client";

import { CrackPolygonOrder } from "./component";

// A section of a page that would otherwise sit under a flat divider panel —
// here the fill is a mud-crack tessellation that keeps re-tiling itself,
// generation by generation, forever.
export default function CrackPolygonOrderDemo() {
  return (
    <main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / crack-polygon-order
      </p>

      <section className="mt-6">
        <h1 className="text-2xl font-semibold text-foreground sm:text-3xl">
          Built to dry out, and re-crack
        </h1>
        <p className="mt-3 text-sm leading-relaxed text-ns-muted">
          Every layer sheds its own tension in order — widest cracks first,
          then the cells between them splitting again, and again, until the
          surface can't subdivide any further.
        </p>
      </section>

      <div className="my-10 h-72 w-full overflow-hidden rounded-lg border border-border">
        <CrackPolygonOrder className="h-full w-full" />
      </div>

      <section>
        <h2 className="text-lg font-semibold text-foreground">
          Rewets, and starts again
        </h2>
        <p className="mt-3 text-sm leading-relaxed text-ns-muted">
          Once the panel is fully tiled it holds, softens back to blank, and
          a new, unrelated set of cracks begins the next cycle.
        </p>
      </section>
    </main>
  );
}

Component

registry/core/crack-polygon-order/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// CrackPolygonOrder — a decorative panel that tiles itself the way a drying
// mud layer actually cracks: a first, widely-spaced generation of cracks
// nucleates and grows across the whole panel; once every primary crack has
// stopped, a 900ms "stress rebuilding" pause elapses and a second generation
// nucleates INSIDE the largest surviving cells and grows outward from a
// point in both directions; the same pause-then-nucleate step repeats once
// more for a third generation. The one rule that makes this desiccation
// cracking rather than a generic Voronoi/crack-seam fill: every new crack
// tip that comes within ~2px of an existing crack (a free surface, zero
// stress) stops there, at close to a right angle — a T-junction, never a
// crossing. Once no cell is worth splitting further the tiling holds fully
// formed, then the whole panel "rewets" (every stroke's opacity eases to 0)
// and a blank cooldown precedes the next, unrelated cycle.
//
// This is a real-time simulation, not a pre-computed path being revealed:
// a 2px-cell occupancy grid records where ink already exists, a random-walk
// tip advances against that grid at a literal 90px/s (segment length
// 6-10px, turning noise +-12deg per segment), and completed generations are
// turned into regions by a flood fill of the still-empty cells so the next
// generation can nucleate inside the biggest 60% of them. Growth is driven
// by real elapsed ms every rAF frame, not snapped to the 6-10px segment
// boundary — a tip's in-progress segment interpolates continuously
// (progressPx / segLen) so the stroke visibly glides at 60fps and only the
// completed, collision-tested segment endpoints are ever committed. Path
// geometry updates happen via direct SVG DOM writes on refs (not React
// state) so ~14 rAF-driven tips can grow every frame without a React
// re-render; React state only changes on the rare event that new <path>
// elements need to mount (once per generation, three times a cycle) or the
// panel resizes.
//
// t0 is deliberately not a fresh, empty panel: on mount the whole tick
// function is run synchronously against a fast, fixed 16ms clock for a
// random 1.5-10.5s of simulated time before the real rAF loop takes over,
// so every visit lands mid-cycle. prefers-reduced-motion runs the same
// synchronous driver all the way to the mature, fully-tiled hold frame and
// then stops for good — the single most structured frame this pattern ever
// produces, all three generations of T-junctions visible at once.
// ---------------------------------------------------------------------------

export interface CrackPolygonOrderProps {
  /** seeds the crack RNG; omit for a fresh, unrepeatable tiling every mount */
  seed?: number;
  className?: string;
}

const CELL = 2; // occupancy-grid cell size, px — matches the ~2px T-junction test
const SPEED_PX_S = 90; // crack tip advance rate
const SEG_MIN = 6;
const SEG_MAX = 10;
const TURN_NOISE = (12 * Math.PI) / 180; // +-12deg per segment
const GEN_PAUSE_MS = 900;
const HOLD_MS = 4000;
const REWET_MS = 2500;
const COOLDOWN_MS = 600;
const MIN_PRIMARY = 4;
const MAX_PRIMARY = 7; // inclusive
const LARGEST_FRACTION = 0.6;
const MIN_REGION_AREA_PX2 = 130; // too small to bother nucleating inside
const MAX_DT_MS = 50; // clamp so a paused-tab / IO resume can't lurch the sim
const FASTFWD_STEP_MS = 16;
const MAX_FASTFWD_ITERS = 4000;
const STROKE_W = 1.5;

type Phase =
  | "primary"
  | "secondary-wait"
  | "secondary"
  | "tertiary-wait"
  | "tertiary"
  | "hold"
  | "rewet"
  | "cooldown";

type Pt = { x: number; y: number };

type Tip = {
  // finalized segment endpoints — collision-tested, never move again
  committed: Pt[];
  // current heading + target length of the segment still in progress
  heading: number;
  segLen: number;
  // px already covered into that in-progress segment, advanced every frame
  // by real elapsed time (not snapped to the segment boundary) so the SVG
  // path visibly glides rather than jumping in 6-10px steps
  progressPx: number;
  done: boolean;
  recentCells: number[];
};

type CrackData = { id: number; gen: 1 | 2 | 3; a: Tip; b: Tip };
type CrackMeta = { id: number; gen: 1 | 2 | 3; initialDA: string; initialDB: string };

/** mulberry32 — small, fast, deterministic given a seed */
function mulberry32(seed: number) {
  let s = seed | 0;
  return () => {
    s = (s + 0x6d2b79f5) | 0;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);

function pointsToPath(pts: Pt[]): string {
  if (pts.length === 0) return "";
  if (pts.length === 1) return `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
  return `M ${pts.map((p) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" L ")}`;
}

/** committed points plus the current in-progress segment's live, interpolated tip — this is what gets drawn every frame */
function renderPoints(tip: Tip): Pt[] {
  if (tip.done) return tip.committed;
  const from = tip.committed[tip.committed.length - 1];
  const t = tip.segLen > 0 ? tip.progressPx / tip.segLen : 0;
  const live: Pt = {
    x: from.x + Math.cos(tip.heading) * tip.segLen * t,
    y: from.y + Math.sin(tip.heading) * tip.segLen * t,
  };
  return [...tip.committed, live];
}

function makeTip(x: number, y: number, heading: number, rng: () => number): Tip {
  return {
    committed: [{ x, y }],
    heading,
    segLen: SEG_MIN + rng() * (SEG_MAX - SEG_MIN),
    progressPx: 0,
    done: false,
    recentCells: [],
  };
}

/** Occupancy grid: one Uint8Array cell per CELLxCELL px, 0 = empty. */
class Grid {
  cols: number;
  rows: number;
  cells: Uint8Array;
  constructor(w: number, h: number) {
    this.cols = Math.max(1, Math.ceil(w / CELL));
    this.rows = Math.max(1, Math.ceil(h / CELL));
    this.cells = new Uint8Array(this.cols * this.rows);
  }
  idx(cx: number, cy: number) {
    return cy * this.cols + cx;
  }
  mark(x: number, y: number, tip: Tip) {
    const cx = Math.min(this.cols - 1, Math.max(0, Math.floor(x / CELL)));
    const cy = Math.min(this.rows - 1, Math.max(0, Math.floor(y / CELL)));
    const i = this.idx(cx, cy);
    this.cells[i] = 1;
    tip.recentCells.push(i);
    if (tip.recentCells.length > 8) tip.recentCells.shift();
  }
  /** true if (x,y)'s 3x3 cell neighbourhood holds ink not owned by `tip`'s recent trail */
  collides(x: number, y: number, tip: Tip): boolean {
    const cx = Math.floor(x / CELL);
    const cy = Math.floor(y / CELL);
    for (let dy = -1; dy <= 1; dy++) {
      for (let dx = -1; dx <= 1; dx++) {
        const nx = cx + dx;
        const ny = cy + dy;
        if (nx < 0 || ny < 0 || nx >= this.cols || ny >= this.rows) continue;
        const i = this.idx(nx, ny);
        if (this.cells[i] !== 0 && !tip.recentCells.includes(i)) return true;
      }
    }
    return false;
  }
}

type Region = { cells: number[]; area: number };

function findRegions(grid: Grid): Region[] {
  const { cols, rows, cells } = grid;
  const visited = new Uint8Array(cols * rows);
  const regions: Region[] = [];
  const stack: number[] = [];
  for (let start = 0; start < cells.length; start++) {
    if (cells[start] !== 0 || visited[start]) continue;
    const regionCells: number[] = [];
    stack.length = 0;
    stack.push(start);
    visited[start] = 1;
    while (stack.length) {
      const i = stack.pop()!;
      regionCells.push(i);
      const cx = i % cols;
      const cy = (i / cols) | 0;
      const neighbours = [
        [cx - 1, cy],
        [cx + 1, cy],
        [cx, cy - 1],
        [cx, cy + 1],
      ];
      for (const [nx, ny] of neighbours) {
        if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
        const ni = ny * cols + nx;
        if (visited[ni] || cells[ni] !== 0) continue;
        visited[ni] = 1;
        stack.push(ni);
      }
    }
    regions.push({ cells: regionCells, area: regionCells.length * CELL * CELL });
  }
  return regions;
}

/** picks a cell biased toward a region's interior: sample a few candidates, keep the one with the most empty neighbours */
function pickSeedCell(region: Region, grid: Grid, rng: () => number): { x: number; y: number } {
  const tries = Math.min(8, region.cells.length);
  let best = region.cells[Math.floor(rng() * region.cells.length)];
  let bestScore = -1;
  for (let t = 0; t < tries; t++) {
    const i = region.cells[Math.floor(rng() * region.cells.length)];
    const cx = i % grid.cols;
    const cy = (i / grid.cols) | 0;
    let score = 0;
    for (const [dx, dy] of [
      [-1, 0],
      [1, 0],
      [0, -1],
      [0, 1],
    ]) {
      const nx = cx + dx;
      const ny = cy + dy;
      if (nx < 0 || ny < 0 || nx >= grid.cols || ny >= grid.rows) continue;
      if (grid.cells[ny * grid.cols + nx] === 0) score++;
    }
    if (score > bestScore) {
      bestScore = score;
      best = i;
    }
  }
  const cx = best % grid.cols;
  const cy = (best / grid.cols) | 0;
  return { x: cx * CELL + CELL / 2, y: cy * CELL + CELL / 2 };
}

/**
 * advances one tip by dtMs of simulated growth against the shared grid.
 * The in-progress segment's `progressPx` moves by real elapsed distance
 * every call (never snapped straight to a segment boundary), so a caller
 * repainting every rAF frame sees the path glide continuously at
 * SPEED_PX_S rather than jump in SEG_MIN..SEG_MAX chunks every ~70-110ms.
 * A segment is only collision/edge-tested — and only then can it terminate
 * the tip at a T-junction — the instant progressPx reaches segLen.
 */
function growTip(tip: Tip, dtMs: number, grid: Grid, w: number, h: number) {
  if (tip.done) return;
  let dt = dtMs;
  let guard = 0;
  while (dt > 0 && !tip.done && guard < 40) {
    guard++;
    const remainingSegPx = tip.segLen - tip.progressPx;
    const availablePx = (SPEED_PX_S / 1000) * dt;

    if (availablePx < remainingSegPx) {
      tip.progressPx += availablePx;
      dt = 0;
      break;
    }

    // enough distance this tick to finish the in-progress segment — spend
    // only the ms it actually needed and carry the rest into the next one
    const neededMs = (remainingSegPx / SPEED_PX_S) * 1000;
    dt -= neededMs;

    const from = tip.committed[tip.committed.length - 1];
    const to = {
      x: from.x + Math.cos(tip.heading) * tip.segLen,
      y: from.y + Math.sin(tip.heading) * tip.segLen,
    };

    if (to.x < 0 || to.x > w || to.y < 0 || to.y > h) {
      const cx = Math.min(w, Math.max(0, to.x));
      const cy = Math.min(h, Math.max(0, to.y));
      tip.committed.push({ x: cx, y: cy });
      grid.mark(cx, cy, tip);
      tip.done = true;
      break;
    }

    // sample a few points along the finished segment: earliest collision
    // wins, and every clean sample gets marked so the raster has no gap
    // another crack could slip through
    let hit = false;
    const samples = 3;
    for (let s = 1; s <= samples; s++) {
      const t = s / samples;
      const px = from.x + (to.x - from.x) * t;
      const py = from.y + (to.y - from.y) * t;
      if (grid.collides(px, py, tip)) {
        tip.committed.push({ x: px, y: py });
        grid.mark(px, py, tip);
        tip.done = true;
        hit = true;
        break;
      }
      grid.mark(px, py, tip);
    }
    if (hit) break;

    tip.committed.push(to);
    tip.heading += (rngFor() - 0.5) * 2 * TURN_NOISE;
    tip.segLen = SEG_MIN + rngFor() * (SEG_MAX - SEG_MIN);
    tip.progressPx = 0;
  }
}

// a tiny module-level RNG stream (reseeded whenever the component's own
// seeded rng is (re)created) avoids threading the seeded generator through
// every tip while still keeping the whole pattern reproducible from `seed`
let sharedRng: () => number = mulberry32(1);
function rngFor() {
  return sharedRng();
}

function spawnCrack(id: number, gen: 1 | 2 | 3, x: number, y: number, rng: () => number): CrackData {
  const heading = rng() * Math.PI * 2;
  const a = makeTip(x, y, heading, rng);
  const b = makeTip(x, y, heading + Math.PI + (rng() - 0.5) * 0.6, rng);
  return { id, gen, a, b };
}

export function CrackPolygonOrder({ seed, className = "" }: CrackPolygonOrderProps) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const gRef = useRef<SVGGElement>(null);
  const pathRefs = useRef<Map<number, { a: SVGPathElement | null; b: SVGPathElement | null }>>(new Map());

  const [dims, setDims] = useState({ w: 0, h: 0 });
  const [cracks, setCracks] = useState<CrackMeta[]>([]);
  const [reduced, setReduced] = useState(false);

  const gridRef = useRef<Grid | null>(null);
  const cracksMapRef = useRef<Map<number, CrackData>>(new Map());
  const cracksDirtyRef = useRef(false);
  const phaseRef = useRef<Phase>("primary");
  const phaseTimerRef = useRef(0);
  const nextIdRef = useRef(0);
  const rngRef = useRef<() => number>(mulberry32(1));
  const rafRef = useRef<number | null>(null);
  const lastTsRef = useRef<number | null>(null);
  const visibleRef = useRef(true);
  const initializedRef = useRef(false);

  useEffect(() => {
    setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  }, []);

  // -- resize: measure the panel, (re)build the grid at that size --
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    let debounce: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      const { width, height } = entry.contentRect;
      if (width < 4 || height < 4) return;
      if (debounce) clearTimeout(debounce);
      debounce = setTimeout(() => {
        const sameSize =
          initializedRef.current && Math.abs(width - dims.w) < 4 && Math.abs(height - dims.h) < 4;
        if (sameSize) return;
        setDims({ w: Math.round(width), h: Math.round(height) });
      }, 120);
    });
    ro.observe(el);
    return () => {
      ro.disconnect();
      if (debounce) clearTimeout(debounce);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // -- visibility gate: pause the growth loop off-screen, resume in place --
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const io = new IntersectionObserver((entries) => {
      visibleRef.current = entries.some((e) => e.isIntersecting);
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const syncCracksToDom = () => {
    if (!cracksDirtyRef.current) return;
    cracksDirtyRef.current = false;
    const meta: CrackMeta[] = Array.from(cracksMapRef.current.values()).map((c) => ({
      id: c.id,
      gen: c.gen,
      initialDA: pointsToPath(renderPoints(c.a)),
      initialDB: pointsToPath(renderPoints(c.b)),
    }));
    setCracks(meta);
  };

  const beginGeneration = (gen: 1 | 2 | 3, w: number, h: number, rng: () => number) => {
    const grid = gridRef.current;
    if (!grid) return;
    if (gen === 1) {
      const count = MIN_PRIMARY + Math.floor(rng() * (MAX_PRIMARY - MIN_PRIMARY + 1));
      for (let i = 0; i < count; i++) {
        const x = rng() * w;
        const y = rng() * h;
        const crack = spawnCrack(nextIdRef.current++, 1, x, y, rng);
        cracksMapRef.current.set(crack.id, crack);
      }
    } else {
      const regions = findRegions(grid).filter((r) => r.area >= MIN_REGION_AREA_PX2);
      regions.sort((a, b) => b.area - a.area);
      const take = Math.ceil(regions.length * LARGEST_FRACTION);
      for (const region of regions.slice(0, take)) {
        const seedPt = pickSeedCell(region, grid, rng);
        const crack = spawnCrack(nextIdRef.current++, gen, seedPt.x, seedPt.y, rng);
        cracksMapRef.current.set(crack.id, crack);
      }
    }
    cracksDirtyRef.current = true;
  };

  const resetCycle = (w: number, h: number) => {
    gridRef.current = new Grid(w, h);
    cracksMapRef.current.clear();
    cracksDirtyRef.current = true;
    phaseRef.current = "primary";
    phaseTimerRef.current = 0;
    if (gRef.current) gRef.current.style.opacity = "1";
    beginGeneration(1, w, h, rngRef.current);
  };

  /** advances the whole simulation by dtMs: growth, phase transitions, generation spawns */
  const tick = (dtMs: number, w: number, h: number) => {
    const grid = gridRef.current;
    if (!grid) return;
    const rng = rngRef.current;
    const phase = phaseRef.current;

    const stepGen = (gen: 1 | 2 | 3) => {
      let allDone = true;
      for (const c of cracksMapRef.current.values()) {
        if (c.gen !== gen) continue;
        growTip(c.a, dtMs, grid, w, h);
        growTip(c.b, dtMs, grid, w, h);
        if (!(c.a.done && c.b.done)) allDone = false;
      }
      return allDone;
    };

    switch (phase) {
      case "primary":
        if (stepGen(1)) {
          phaseRef.current = "secondary-wait";
          phaseTimerRef.current = 0;
        }
        break;
      case "secondary-wait":
        phaseTimerRef.current += dtMs;
        if (phaseTimerRef.current >= GEN_PAUSE_MS) {
          beginGeneration(2, w, h, rng);
          phaseRef.current = "secondary";
        }
        break;
      case "secondary":
        if (stepGen(2)) {
          phaseRef.current = "tertiary-wait";
          phaseTimerRef.current = 0;
        }
        break;
      case "tertiary-wait":
        phaseTimerRef.current += dtMs;
        if (phaseTimerRef.current >= GEN_PAUSE_MS) {
          beginGeneration(3, w, h, rng);
          phaseRef.current = "tertiary";
        }
        break;
      case "tertiary":
        if (stepGen(3)) {
          phaseRef.current = "hold";
          phaseTimerRef.current = 0;
        }
        break;
      case "hold":
        phaseTimerRef.current += dtMs;
        if (phaseTimerRef.current >= HOLD_MS) {
          phaseRef.current = "rewet";
          phaseTimerRef.current = 0;
        }
        break;
      case "rewet": {
        phaseTimerRef.current += dtMs;
        const t = Math.min(1, phaseTimerRef.current / REWET_MS);
        if (gRef.current) gRef.current.style.opacity = String(1 - easeInOutCubic(t));
        if (phaseTimerRef.current >= REWET_MS) {
          gridRef.current = new Grid(w, h);
          cracksMapRef.current.clear();
          cracksDirtyRef.current = true;
          if (gRef.current) gRef.current.style.opacity = "1";
          phaseRef.current = "cooldown";
          phaseTimerRef.current = 0;
        }
        break;
      }
      case "cooldown":
        phaseTimerRef.current += dtMs;
        if (phaseTimerRef.current >= COOLDOWN_MS) {
          phaseRef.current = "primary";
          phaseTimerRef.current = 0;
          beginGeneration(1, w, h, rng);
        }
        break;
    }
  };

  // -- write per-frame growth straight to the DOM, bypassing React --
  const paintFrame = () => {
    for (const c of cracksMapRef.current.values()) {
      const refs = pathRefs.current.get(c.id);
      if (!refs) continue;
      if (refs.a) refs.a.setAttribute("d", pointsToPath(renderPoints(c.a)));
      if (refs.b) refs.b.setAttribute("d", pointsToPath(renderPoints(c.b)));
    }
  };

  // -- init + main loop: waits for a real measurement, then either freezes
  // at the mature frame (reduced motion) or fast-forwards into a random
  // mid-cycle point before handing off to a live rAF loop --
  useEffect(() => {
    if (dims.w < 4 || dims.h < 4) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      rngRef.current = mulberry32(seed ?? Math.floor(Math.random() * 2 ** 31));
      sharedRng = rngRef.current;
      gridRef.current = new Grid(dims.w, dims.h);
      cracksMapRef.current.clear();
      phaseRef.current = "primary";
      phaseTimerRef.current = 0;
      beginGeneration(1, dims.w, dims.h, rngRef.current);
      let iters = 0;
      while ((phaseRef.current as Phase) !== "hold" && iters < MAX_FASTFWD_ITERS) {
        tick(FASTFWD_STEP_MS, dims.w, dims.h);
        iters++;
      }
      syncCracksToDom();
      initializedRef.current = true;
      return;
    }

    if (!initializedRef.current) {
      rngRef.current = mulberry32(seed ?? Math.floor(Math.random() * 2 ** 31));
      sharedRng = rngRef.current;
      resetCycle(dims.w, dims.h);
      const leadInMs = 1500 + rngRef.current() * 9000;
      let simulated = 0;
      let iters = 0;
      while (simulated < leadInMs && iters < MAX_FASTFWD_ITERS) {
        tick(FASTFWD_STEP_MS, dims.w, dims.h);
        simulated += FASTFWD_STEP_MS;
        iters++;
      }
      syncCracksToDom();
      paintFrame();
      initializedRef.current = true;
    } else {
      // a real resize after init: rebuild fresh at the new size, no lead-in
      resetCycle(dims.w, dims.h);
      syncCracksToDom();
      paintFrame();
    }

    const loop = (ts: number) => {
      rafRef.current = requestAnimationFrame(loop);
      if (!visibleRef.current) {
        lastTsRef.current = ts;
        return;
      }
      const last = lastTsRef.current ?? ts;
      const dt = Math.min(MAX_DT_MS, ts - last);
      lastTsRef.current = ts;
      if (dt <= 0) return;
      tick(dt, dims.w, dims.h);
      syncCracksToDom();
      paintFrame();
    };
    rafRef.current = requestAnimationFrame(loop);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
      lastTsRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dims.w, dims.h, seed]);

  return (
    <div
      ref={wrapRef}
      data-crack-polygon-order
      aria-hidden
      className={`ns-crack-polygon-order relative h-full w-full overflow-hidden ${className}`}
    >
      <svg
        width="100%"
        height="100%"
        viewBox={`0 0 ${Math.max(1, dims.w)} ${Math.max(1, dims.h)}`}
        preserveAspectRatio="none"
        className="block"
      >
        <rect x={0} y={0} width={dims.w} height={dims.h} fill="var(--background)" />
        <g ref={gRef}>
          {cracks.map((c) => (
            <g key={c.id}>
              <path
                ref={(el) => {
                  const entry = pathRefs.current.get(c.id) ?? { a: null, b: null };
                  entry.a = el;
                  pathRefs.current.set(c.id, entry);
                }}
                d={c.initialDA}
                fill="none"
                stroke="var(--foreground)"
                strokeWidth={STROKE_W}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
              <path
                ref={(el) => {
                  const entry = pathRefs.current.get(c.id) ?? { a: null, b: null };
                  entry.b = el;
                  pathRefs.current.set(c.id, entry);
                }}
                d={c.initialDB}
                fill="none"
                stroke="var(--foreground)"
                strokeWidth={STROKE_W}
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            </g>
          ))}
        </g>
      </svg>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/crack-polygon-order.tsx, so point the import there.

Use when a section divider or decorative panel fill whose tessellation is built from a real generation HIERARCHY — first-generation cracks growing widely spaced, then second- and third-generation cracks nucleating inside the largest remaining cells and T-junctioning into older cracks at right angles, never crossing them, on an unbounded rest-hold-rewet-cooldown cycle; pick craze-rule instead for a thin 40px <hr>-replacement divider that draws in once via IntersectionObserver and idles (a single branching fracture line, not a full tessellation, and no generation hierarchy at all), or compare-crack-seam instead when the fracture needs to be a draggable, interactive seam splitting a before/after image comparison rather than an ambient, non-interactive panel fill.

Install

npx shadcn add https://design.helpmarq.com/r/crack-polygon-order.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build <CrackPolygonOrder seed? className?> as a full-bleed fill for a card-scale panel (give it explicit height via className/a wrapper; the component itself is h-full w-full). STRUCTURE: a plain <div data-crack-polygon-order aria-hidden> holding one inline <svg viewBox="0 0 {measuredWidth} {measuredHeight}" preserveAspectRatio="none"> whose viewBox is set to the container's own measured CSS pixel size via a debounced ResizeObserver (120ms) — unlike an abstract stretched viewBox, 1 viewBox unit here equals 1 on-screen px, because every real-numbers threshold in the mechanic (90px/s growth, a ~2px T-junction test, a ~900px^2 minimum polygon area) is stated in real panel pixels. A <rect> filled var(--background) sits under everything; every crack stroke is var(--foreground) at 1.5px with vector-effect="non-scaling-stroke" — never var(--border), which falls below the light-theme legibility floor at hairline width, and never any per-polygon fill (the tessellation must read from crack lines alone, not shaded cell faces). SIMULATION, not a pre-computed reveal: a Uint8Array occupancy grid at 2px cells (Grid class) records where ink already exists. Each crack is a single nucleation point growing TWO tips outward in roughly opposite headings (heading and heading+PI, +-~17deg jitter) — this is what makes primary cracks read as a widely-spaced network and what makes secondary/tertiary cracks visibly bisect the cell they were born in rather than doodling from one wall. A tip advances by discrete 3-5px segments at a rate that nets 90px/s, turning +-12deg per segment (seeded per-tip via a module-level `sharedRng` stream so the whole pattern is reproducible from one `seed`), and samples 3 points along every new segment against the grid's 3x3-cell neighbourhood (skipping the tip's own last 8 marked cells so it can't immediately T-junction into itself) — the instant a sample lands within that ~2px neighbourhood of ANY other ink, growth stops exactly there: the T-junction, and the reason later cracks can never be seen crossing an earlier one. Every generation is driven by an explicit phase machine (primary -> secondary-wait -> secondary -> tertiary-wait -> tertiary -> hold -> rewet -> cooldown -> primary...): primary spawns 4-7 nucleation points scattered uniformly at t=0; once every primary tip is done (edge or T-junction), a 900ms wait elapses, then a flood fill (findRegions, 4-connected BFS over empty grid cells) finds every still-open polygon, regions under 130px^2 are dropped, the rest sorted by area descending, and cracks nucleate inside the top ceil(60%) of what's left — same 900ms-wait-then-nucleate step repeats once more for the third generation, then the tiling holds for 4000ms untouched. REWET: over the following 2500ms the whole crack <g>'s opacity eases 1 -> 0 on an ease-in-out-cubic computed per frame (not a CSS transition, so it stays exactly in step with the same rAF clock driving growth) — the panel then clears (grid reset, crack map emptied) as a genuine return to blank, not just an invisible-but-still-there state, holds blank for a 600ms cooldown, and the next cycle's primaries begin. Full period is non-uniform by design, roughly 14-17s (6-8s to reach maturity + 4s hold + 2.5s rewet + 0.6s cooldown). PERFORMANCE: geometry writes for already-mounted <path> elements happen via direct refs.current.setAttribute('d', ...) inside the rAF callback, bypassing React entirely for per-frame growth; React state (the `cracks` array, id+gen+initialD only) only updates on the rare event a new generation's crack set changes (three times a cycle) or the panel resizes, gated by a `cracksDirtyRef` flag so a mid-fast-forward burst of spawns collapses into a single re-render. RESUME SAFETY: an IntersectionObserver sets a `visibleRef` the rAF loop checks before ticking (off-screen panels stop simulating but keep scheduling frames so they resume instantly back in view), and every dt fed to the simulation is clamped to 50ms regardless of source, so a backgrounded tab or a slow resize can never hand the sim a multi-second jump that would skip whole generations invisibly. THE t0 REQUIREMENT: a brand-new mount never starts from an empty panel — after the first valid ResizeObserver measurement, the exact same tick() function used by the live loop is called synchronously in a tight while-loop on a fixed 16ms clock for a random 1500-10500ms of simulated time (capped at 4000 iterations) BEFORE the real rAF loop ever starts, landing every visit at an arbitrary, already-partially-tiled mid-cycle frame; a real resize after that initial mount rebuilds the grid at the new size and restarts cleanly at a fresh primary generation (no repeated lead-in — a resize is not a new visit). REDUCED MOTION: on mount, if prefers-reduced-motion matches, skip the rAF loop entirely and instead run that same tick() function synchronously and repeatedly until phaseRef reaches exactly 'hold' (capped at 4000 iterations), then stop for good — the panel renders once, fully tiled, all three generations' T-junctions visible simultaneously, and never rewets. Zero dependencies, no canvas — SVG+DOM+CSS only, and the crack RNG (mulberry32, seeded from the optional `seed` prop or Math.random() per mount) is the only source of variation. DIFFERS FROM craze-rule: that component draws ONE branching fracture line across a fixed 40px divider strip, once, via IntersectionObserver, and never subdivides a cell — no generation hierarchy, no polygon tessellation, no ongoing simulation once armed. DIFFERS FROM compare-crack-seam: that fracture is a single draggable Voronoi-cell seam that IS a before/after image divider, spawning micro-fissures on drag velocity; crack-polygon-order has no pointer interaction, no comparison images, and its cracks free-branch across the whole panel on their own clock rather than tracking a cursor.

Props

PropTypeDefaultDescription
seed?number—seeds the crack RNG; omit for a fresh, unrepeatable tiling every mount
className?string——