Skip to main content

ns-ui / loud

Orb Web Construction

A full-bleed hero background where a web builds in the real orb-weaver sequence: bridge, frame, radii laid hub-outward, a dashed scaffold spiral hub-outward, then a solid capture spiral laid outside-back-to-hub that visibly replaces the scaffold turn by turn. It then rests, tears a random sector, and rebuilds only that sector before resting again.

Usage

registry/loud/orb-web-construction/demo.tsx
"use client";

import { OrbWebConstruction } from "./component";

// The web builds itself on its own internal clock — everything below is
// ordinary DOM type sitting over it behind a scrim so copy stays legible
// against however much silk has been laid at any given moment.
export default function OrbWebConstructionDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <OrbWebConstruction>
        <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-end gap-4 px-6 pb-14 text-center sm:pb-20">
          <div className="flex flex-col items-center gap-3 rounded-lg bg-background/80 px-7 py-5 backdrop-blur-md">
            <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
              ns-ui / orb-web-construction
            </p>
            <p className="max-w-sm text-sm text-foreground sm:text-base">
              Bridge, frame, radii, scaffold spiral, capture spiral — built in
              the spider&apos;s own order, then torn and repaired one sector
              at a time.
            </p>
          </div>
          <a
            href="#docs"
            className="pointer-events-auto mt-1 inline-flex w-fit items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
          >
            Read the docs
          </a>
        </div>
      </OrbWebConstruction>
    </main>
  );
}

Component

registry/loud/orb-web-construction/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// OrbWebConstruction — a full-bleed hero background that builds a web in the
// real orb-weaver sequence (Zschokke 1999): bridge/frame first, then radii
// laid hub-outward, then a temporary non-sticky auxiliary spiral hub-outward
// (structural scaffold, dashed), then the permanent sticky capture spiral
// laid from the OUTSIDE back toward the hub, replacing the auxiliary spiral
// turn by turn as it goes. Once finished, the web rests, a random sector
// tears, and the SAME sequence (radii, then spiral) rebuilds only that
// sector while the rest of the web stays put — real, documented orb-weaver
// damage-repair behaviour.
//
// The auxiliary and capture spirals share one underlying curve
// (spiralPoint(u), u=0 at hub .. u=1 at outer edge) so the "replacement" the
// brief calls for reads as one spiral changing character (dashed -> solid)
// rather than two visually distinct curves swapping — a boundary value
// `uCap` marks how far in from the outer edge the capture silk has replaced
// the scaffold; below it the segment renders dashed, at/above it solid.
//
// No pointer/keyboard interaction: this is a decorative full-bleed backdrop,
// the canvas is aria-hidden, children render as ordinary DOM above it.
// prefers-reduced-motion skips straight to the finished, mid-rest web (no
// build sequence, no tear/repair cycle ever runs).
// ---------------------------------------------------------------------------

const FRAME_ANCHORS = 3;
const FRAME_SEG_DUR = 0.4; // s, sequential
const FRAME_TOTAL = FRAME_ANCHORS * FRAME_SEG_DUR;

const RADII_STAGGER = 0.09; // s between spoke starts
const RADII_DRAW_DUR = 0.26; // s for one spoke to finish drawing
const RADII_JITTER_DEG = 4; // +/- per spoke

const AUX_DURATION = 2.1; // s, hub -> outer edge, dashed scaffold
const CAPTURE_DURATION = 3.1; // s, outer edge -> hub, solid capture silk
const SPIRAL_TURNS = 5.5; // shared by both aux and capture curves

const REST_MIN = 6;
const REST_MAX = 9;
const TEAR_DUR = 0.18; // s
const WEDGE_MIN_DEG = 40;
const WEDGE_MAX_DEG = 70;

const OUTER_FRACTION = 0.46; // outerRadius = min(w,h) * this

// xorshift32 — deterministic across mounts, unlike Math.random, so the
// resting frame this component ships doesn't drift screenshot to screenshot
function makeRng(seed: number) {
  let s = seed >>> 0 || 1;
  return () => {
    s ^= s << 13;
    s ^= s >>> 17;
    s ^= s << 5;
    return ((s >>> 0) % 1_000_000) / 1_000_000;
  };
}

type RGB = [number, number, number];

function parseHex(raw: string): RGB | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function rgba(c: RGB, a: number): string {
  return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
}

const TAU = Math.PI * 2;

function norm2pi(a: number): number {
  let x = a % TAU;
  if (x < 0) x += TAU;
  return x;
}

interface Radius {
  angle: number; // radians, fixed at rebuild
  progress: number; // 0..1 fraction drawn, updated per frame
}

interface Wedge {
  startDeg: number;
  widthDeg: number;
  indices: number[]; // radii indices whose angle falls inside the wedge
  uCap: number; // 0..1, spiral replacement boundary local to this wedge
}

function angleInWedge(angleRad: number, w: Wedge): boolean {
  const a = (norm2pi(angleRad) * 180) / Math.PI;
  const start = ((w.startDeg % 360) + 360) % 360;
  const end = start + w.widthDeg;
  if (end <= 360) return a >= start && a <= end;
  return a >= start || a <= end - 360;
}

// point on the shared aux/capture curve, u=0 at hub, u=1 at outer edge
function spiralPoint(hubX: number, hubY: number, outerR: number, u: number): [number, number] {
  const angle = u * SPIRAL_TURNS * TAU;
  const r = u * outerR;
  return [hubX + Math.cos(angle) * r, hubY + Math.sin(angle) * r];
}

type Phase = "frame" | "radii" | "aux" | "capture" | "rest" | "tear" | "repairRadii" | "repairSpiral";

export interface OrbWebConstructionProps {
  children?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
}

export function OrbWebConstruction({ children, className = "", style }: OrbWebConstructionProps) {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    let cssW = 0;
    let cssH = 0;
    let dpr = 1;
    let hubX = 0;
    let hubY = 0;
    let outerR = 0;

    let rng = makeRng(0x51ffab1e);
    let radii: Radius[] = [];
    let n = 0;

    let phase: Phase = "frame";
    let phaseStart = 0; // seconds, same clock as `now`
    let restDuration = REST_MIN;
    let wedge: Wedge | null = null;
    let capUCap = 1; // global capture boundary during the very first build only

    // --- palette ------------------------------------------------------------
    let foreground: RGB = [230, 230, 230];
    let border: RGB = [58, 58, 58];
    let isLight = false;
    const readColors = () => {
      const cs = getComputedStyle(document.documentElement);
      foreground = parseHex(cs.getPropertyValue("--foreground")) ?? foreground;
      border = parseHex(cs.getPropertyValue("--border")) ?? border;
      isLight = !document.documentElement.classList.contains("dark");
    };
    readColors();

    // --- build/reset ----------------------------------------------------------
    const resetCycle = () => {
      n = Math.max(16, Math.min(28, Math.round(Math.min(cssW, cssH) / 60)));
      rng = makeRng(0x51ffab1e);
      radii = new Array(n).fill(0).map((_, i) => {
        const base = (i / n) * TAU;
        const jitter = ((rng() - 0.5) * 2 * RADII_JITTER_DEG * Math.PI) / 180;
        return { angle: base + jitter, progress: 0 };
      });
      phase = "frame";
      phaseStart = 0;
      wedge = null;
      capUCap = 1;
    };

    const pickWedge = (): Wedge => {
      const startDeg = rng() * 360;
      const widthDeg = WEDGE_MIN_DEG + rng() * (WEDGE_MAX_DEG - WEDGE_MIN_DEG);
      const indices: number[] = [];
      const probe: Wedge = { startDeg, widthDeg, indices, uCap: 0 };
      for (let i = 0; i < radii.length; i++) {
        if (angleInWedge(radii[i]!.angle, probe)) indices.push(i);
      }
      // never tear an empty wedge — widen until at least one spoke is caught
      let guard = 0;
      while (indices.length === 0 && guard < 8) {
        probe.widthDeg += 15;
        indices.length = 0;
        for (let i = 0; i < radii.length; i++) {
          if (angleInWedge(radii[i]!.angle, probe)) indices.push(i);
        }
        guard++;
      }
      return probe;
    };

    // --- phase advance --------------------------------------------------------
    const advance = (now: number) => {
      const elapsed = now - phaseStart;
      switch (phase) {
        case "frame":
          if (elapsed >= FRAME_TOTAL) {
            phase = "radii";
            phaseStart = now;
          }
          break;
        case "radii": {
          const dur = n * RADII_STAGGER + RADII_DRAW_DUR;
          if (elapsed >= dur) {
            phase = "aux";
            phaseStart = now;
          }
          break;
        }
        case "aux":
          if (elapsed >= AUX_DURATION) {
            phase = "capture";
            phaseStart = now;
            capUCap = 1;
          }
          break;
        case "capture":
          if (elapsed >= CAPTURE_DURATION) {
            phase = "rest";
            phaseStart = now;
            capUCap = 0;
            restDuration = REST_MIN + rng() * (REST_MAX - REST_MIN);
          }
          break;
        case "rest":
          if (elapsed >= restDuration) {
            phase = "tear";
            phaseStart = now;
            wedge = pickWedge();
            wedge.uCap = 0;
          }
          break;
        case "tear":
          if (elapsed >= TEAR_DUR) {
            phase = "repairRadii";
            phaseStart = now;
          }
          break;
        case "repairRadii": {
          const count = wedge ? wedge.indices.length : 0;
          const dur = count * RADII_STAGGER + RADII_DRAW_DUR;
          if (elapsed >= dur) {
            phase = "repairSpiral";
            phaseStart = now;
          }
          break;
        }
        case "repairSpiral": {
          const widthDeg = wedge ? wedge.widthDeg : WEDGE_MIN_DEG;
          const dur = Math.max(0.8, (AUX_DURATION + CAPTURE_DURATION) * (widthDeg / 360));
          if (elapsed >= dur) {
            phase = "rest";
            phaseStart = now;
            wedge = null;
            restDuration = REST_MIN + rng() * (REST_MAX - REST_MIN);
          }
          break;
        }
      }
    };

    // --- per-frame state update (radii progress, wedge uCap) ------------------
    const updateState = (now: number) => {
      const elapsed = now - phaseStart;
      if (phase === "radii") {
        for (let i = 0; i < radii.length; i++) {
          const start = i * RADII_STAGGER;
          const p = Math.max(0, Math.min(1, (elapsed - start) / RADII_DRAW_DUR));
          radii[i]!.progress = p;
        }
      } else if (phase === "frame") {
        for (let i = 0; i < radii.length; i++) radii[i]!.progress = 0;
      } else {
        for (let i = 0; i < radii.length; i++) {
          if (radii[i]!.progress < 1 && !(wedge && wedge.indices.includes(i))) radii[i]!.progress = 1;
        }
      }

      if (phase === "aux") {
        capUCap = 1; // nothing solid yet, only the growing dashed scaffold
      } else if (phase === "capture") {
        const p = Math.max(0, Math.min(1, elapsed / CAPTURE_DURATION));
        capUCap = 1 - p;
      }

      if (wedge) {
        if (phase === "tear") {
          const p = Math.max(0, Math.min(1, elapsed / TEAR_DUR));
          for (const i of wedge.indices) radii[i]!.progress = 1 - p;
          wedge.uCap = p; // 0 (solid) -> 1 (erased)
        } else if (phase === "repairRadii") {
          for (let k = 0; k < wedge.indices.length; k++) {
            const i = wedge.indices[k]!;
            const start = k * RADII_STAGGER;
            const p = Math.max(0, Math.min(1, (elapsed - start) / RADII_DRAW_DUR));
            radii[i]!.progress = p;
          }
          wedge.uCap = 1; // spiral still fully absent in this wedge
        } else if (phase === "repairSpiral") {
          const widthDeg = wedge.widthDeg;
          const dur = Math.max(0.8, (AUX_DURATION + CAPTURE_DURATION) * (widthDeg / 360));
          const p = Math.max(0, Math.min(1, elapsed / dur));
          wedge.uCap = 1 - p; // 1 (erased) -> 0 (rebuilt, solid)
          for (const i of wedge.indices) radii[i]!.progress = 1;
        }
      }
    };

    // --- render -----------------------------------------------------------
    // `t` throughout render/draw* is always seconds-since-mount (same clock
    // advance()/updateState() use), never a raw performance.now() timestamp.
    const auxGrowLimit = (t: number) => {
      if (phase === "aux") return Math.max(0, Math.min(1, (t - phaseStart) / AUX_DURATION));
      return 1;
    };

    const drawFrame = (elapsedInPhase: number, active: boolean) => {
      const col = rgba(border, isLight ? 0.55 : 0.4);
      ctx.strokeStyle = col;
      ctx.lineWidth = 1;
      ctx.setLineDash([]);
      const pts: [number, number][] = [];
      for (let i = 0; i < FRAME_ANCHORS; i++) {
        const a = (i / FRAME_ANCHORS) * TAU - Math.PI / 2;
        pts.push([hubX + Math.cos(a) * outerR, hubY + Math.sin(a) * outerR]);
      }
      for (let i = 0; i < FRAME_ANCHORS; i++) {
        const segStart = i * FRAME_SEG_DUR;
        const p = active ? Math.max(0, Math.min(1, (elapsedInPhase - segStart) / FRAME_SEG_DUR)) : 1;
        if (p <= 0) continue;
        const a = pts[i]!;
        const b = pts[(i + 1) % FRAME_ANCHORS]!;
        const x = a[0] + (b[0] - a[0]) * p;
        const y = a[1] + (b[1] - a[1]) * p;
        ctx.beginPath();
        ctx.moveTo(a[0], a[1]);
        ctx.lineTo(x, y);
        ctx.stroke();
      }
    };

    const drawRadii = () => {
      ctx.strokeStyle = rgba(border, isLight ? 0.6 : 0.45);
      ctx.lineWidth = 1;
      ctx.setLineDash([]);
      for (const r of radii) {
        if (r.progress <= 0) continue;
        const x = hubX + Math.cos(r.angle) * outerR * r.progress;
        const y = hubY + Math.sin(r.angle) * outerR * r.progress;
        ctx.beginPath();
        ctx.moveTo(hubX, hubY);
        ctx.lineTo(x, y);
        ctx.stroke();
      }
    };

    // draws the shared spiral curve, splitting each sample into dashed
    // (not-yet-replaced scaffold) or solid (capture silk) runs, using the
    // wedge's local uCap where the sample angle falls inside the active
    // wedge and the global capUCap everywhere else.
    const drawSpiral = (builtLimitU: number) => {
      const steps = Math.max(60, Math.round(SPIRAL_TURNS * 48));
      let prevSolid: boolean | null = null;
      let path: [number, number][] = [];
      const flush = (solid: boolean) => {
        if (path.length < 2) {
          path = [];
          return;
        }
        ctx.strokeStyle = solid ? rgba(foreground, isLight ? 0.45 : 0.35) : rgba(border, isLight ? 0.55 : 0.4);
        ctx.setLineDash(solid ? [] : [2, 3]);
        ctx.lineWidth = solid ? 1.1 : 0.9;
        ctx.beginPath();
        ctx.moveTo(path[0]![0], path[0]![1]);
        for (let k = 1; k < path.length; k++) ctx.lineTo(path[k]![0], path[k]![1]);
        ctx.stroke();
        path = [];
      };
      for (let s = 0; s <= steps; s++) {
        const u = (s / steps) * builtLimitU;
        if (u > builtLimitU) break;
        const [x, y] = spiralPoint(hubX, hubY, outerR, u);
        const angle = u * SPIRAL_TURNS * TAU;
        const local = wedge && angleInWedge(angle, wedge) ? wedge.uCap : capUCap;
        const solid = u >= local;
        if (prevSolid !== null && prevSolid !== solid) {
          path.push([x, y]);
          flush(prevSolid);
          path.push([x, y]);
        } else {
          path.push([x, y]);
        }
        prevSolid = solid;
      }
      if (prevSolid !== null) flush(prevSolid);
      ctx.setLineDash([]);
    };

    const render = (t: number) => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.scale(dpr, dpr);

      const framePhaseElapsed = phase === "frame" ? t - phaseStart : FRAME_TOTAL;
      drawFrame(framePhaseElapsed, phase === "frame");

      if (phase !== "frame") drawRadii();

      if (phase === "aux") {
        drawSpiral(auxGrowLimit(t));
      } else if (phase !== "frame" && phase !== "radii") {
        // capture / rest / tear / repair — full spiral extent, dashed vs
        // solid decided per-sample inside drawSpiral via capUCap/wedge.uCap
        drawSpiral(1);
      }

      ctx.restore();
    };

    // --- sizing -------------------------------------------------------------
    const applyBacking = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(cssW * dpr));
      canvas.height = Math.max(1, Math.round(cssH * dpr));
      canvas.style.width = `${cssW}px`;
      canvas.style.height = `${cssH}px`;
    };

    let resizeTimer = 0;
    let everSized = false;
    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) return;
      const changed = Math.abs(rect.width - cssW) > 0.5 || Math.abs(rect.height - cssH) > 0.5;
      cssW = rect.width;
      cssH = rect.height;
      hubX = cssW / 2;
      hubY = cssH / 2;
      outerR = Math.min(cssW, cssH) * OUTER_FRACTION;
      applyBacking();
      if (changed && !everSized) {
        // first real measurement — build and paint immediately, no debounce
        everSized = true;
        resetCycle();
        if (reduced) primeReduced();
        renderNow();
      } else if (changed) {
        window.clearTimeout(resizeTimer);
        resizeTimer = window.setTimeout(() => {
          resetCycle();
          if (reduced) primeReduced();
          renderNow();
        }, 200);
      } else {
        renderNow();
      }
    };

    const renderNow = () => render((performance.now() - mountClock) / 1000);

    // reduced motion: run the phase machine forward synchronously in fixed
    // small steps until the web is finished and sitting mid-rest, then paint
    // that one static frame and never schedule another
    const primeReduced = () => {
      let t = 0;
      const dt = 0.05;
      const targetRestElapsed = 3; // land solidly inside "rest", not at its edge
      let guard = 0;
      while (guard < 4000) {
        t += dt;
        advance(t);
        updateState(t);
        if (phase === "rest" && t - phaseStart >= targetRestElapsed) break;
        guard++;
      }
    };

    // --- loop -----------------------------------------------------------
    let raf = 0;
    let visible = true;
    let staticMode = reduced;
    // fixed at first paint below, before resetCycle()/resize() run, so every
    // renderNow() call (including the very first synchronous one) reads a
    // consistent, small, near-zero `t` rather than a stale-vs-fresh mix
    const mountClock = performance.now();

    const loop = (now: number) => {
      const t = (now - mountClock) / 1000;
      advance(t);
      updateState(t);
      render(t);
      if (visible && !document.hidden && !staticMode) {
        raf = requestAnimationFrame(loop);
      } else {
        raf = 0;
      }
    };

    const wake = () => {
      if (raf || staticMode || !visible || document.hidden) return;
      // mountClock stays fixed for the component's whole life — `t` is real
      // elapsed wall time since mount, so a sleep period just means several
      // pending phase transitions cascade through on the first few frames
      // back, which is harmless since nobody was watching while asleep.
      raf = requestAnimationFrame(loop);
    };
    const sleep = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    resize(); // first real measurement runs resetCycle() itself, see above

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries.some((e) => e.isIntersecting);
        if (visible) wake();
        else sleep();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

    const onVis = () => {
      if (document.hidden) sleep();
      else wake();
    };
    document.addEventListener("visibilitychange", onVis);

    const applyMode = () => {
      staticMode = reduced;
      if (staticMode) {
        sleep();
        resetCycle();
        primeReduced();
        renderNow();
      } else {
        resetCycle();
        wake();
      }
    };
    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

    const themeObserver = new MutationObserver(() => {
      readColors();
      renderNow();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    // resize()'s first-measurement branch above already primed and painted
    // the initial frame (reduced or not) — only the live rAF loop is left
    if (!reduced) raf = requestAnimationFrame(loop);

    return () => {
      sleep();
      ro.disconnect();
      io.disconnect();
      themeObserver.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
      window.clearTimeout(resizeTimer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      ref={wrapRef}
      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
      {children ? <div className="relative z-[1] h-full w-full">{children}</div> : null}
    </div>
  );
}

OrbWebConstruction.displayName = "OrbWebConstruction";

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

Use when Pick orb-web-construction when the backdrop should read as a real, sequenced BUILD process with a legible before/after at each phase — bridge/frame, then radii, then a scaffold spiral visibly replaced by a capture spiral outside-in, then ongoing localized tear/repair — where the construction ORDER itself is the point. Pick auxin-canal instead when the network should grow by space colonisation through the exact negative space the page copy leaves open (avoidance-driven topology, not a fixed hub/radii/spiral geometry) and rest via a drain-to-background cycle rather than sector damage-repair. Pick forage-vein when the mechanic should be a continuously-foraging trail network reacting to moving DOM food sources rather than a structure with a fixed, finished shape between damage events.

Install

npx shadcn add https://design.helpmarq.com/r/orb-web-construction.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 <OrbWebConstruction children? className? style?> as a full-bleed canvas hero background that lays down a web in the real orb-weaver construction order (Zschokke 1999), never a generic radial reveal. HUB at container center; outerRadius = min(w,h) * 0.46. PHASE 1 FRAME (1.2s total): three anchor threads at 120 degrees around outerRadius are drawn sequentially, 0.4s each, forming a triangle around the hub before anything else appears. PHASE 2 RADII: `n = clamp(round(min(w,h)/60), 16, 28)` spokes, evenly angled with +/-4deg per-spoke jitter, are drawn hub-to-outerRadius sequentially — one spoke starts every 90ms and takes 260ms to finish, so 2-3 are mid-draw at any moment; full radii phase takes roughly n*90ms+260ms. PHASE 3 AUXILIARY SPIRAL (2.1s): a dashed, low-alpha scaffold spiral grows from the hub outward along a shared curve `spiralPoint(u)` (u=0 hub, u=1 outer edge, angle = u*5.5 turns*2*PI, radius = u*outerRadius) — this is the SAME curve the capture spiral reuses in phase 4, so the 'replacement' reads as one spiral changing character rather than two different curves swapping. PHASE 4 CAPTURE SPIRAL (3.1s): a solid, brighter capture thread is laid from the OUTER edge (u=1) back toward the hub (u=0); a single scalar `uCap` (1 at capture-phase start, ramping to 0 over 3.1s) marks the boundary — samples along the shared curve with u>=uCap render solid (already replaced), samples with u<uCap render dashed (still original scaffold, not yet reached) — so the viewer watches the solid line eat the dashed one from the outside in, turn by turn. Once capture finishes the web rests fully built (uCap=0 everywhere) for a randomized 6-9s. TEAR: a random 40-70deg wedge is picked (widened in 15deg steps if it happens to catch zero spokes) and every spoke + spiral segment inside it erases over 180ms (spoke progress and a wedge-local uCap both ramp from built to erased in that window) while the rest of the web stays fully intact and static throughout. REPAIR: the SAME sequence (radii, then spiral) rebuilds ONLY the torn wedge — its spokes redraw staggered 90ms/260ms as in phase 2, then a wedge-local uCap ramps from 1 (erased) back to 0 (solid) over max(0.8s, (2.1+3.1)*wedgeWidthDeg/360) — while every spoke and spiral segment outside the wedge is untouched the entire time. After repair the web rests again, then tears a fresh random wedge, indefinitely (full cycle after the first build is roughly 9-13s: 6-9s rest + 0.18s tear + ~1-3s repair-radii + ~1-2s repair-spiral). All phase timing runs off a single continuous seconds-since-mount clock (`t`), never a per-frame delta accumulator, so phase transitions stay correct across variable frame rates and even across an off-screen sleep (IntersectionObserver/visibilitychange pause the rAF loop entirely; on wake, any backlog of phase transitions the wall clock accrued cascades through in the next few frames, which is inaudible/invisible since nobody was watching while paused). Every random choice (spoke jitter, wedge angle/width, rest duration) is driven by a deterministic xorshift32 PRNG seeded once per build, not Math.random, so the shipped resting frame is reproducible mount to mount. RENDER: capture-spiral solid segments use --foreground at ~0.35 alpha (dark) / ~0.45 alpha (light, where the token's contrast against --background compresses); frame threads, radii and dashed scaffold segments use --border at ~0.4 (dark) / ~0.55 (light) alpha; both colours are read via getComputedStyle(document.documentElement) at mount and re-read on a documentElement class MutationObserver, and light-vs-dark is decided from `document.documentElement.classList.contains('dark')`. ResizeObserver rebuilds the whole web (radii angles, hub position, outer radius, phase reset to the beginning) on a real size change, debounced 200ms except the very first real measurement which builds and paints immediately with no debounce; IntersectionObserver (threshold 0) and visibilitychange pause the single rAF loop off-screen or in a hidden tab. prefers-reduced-motion runs the identical phase-advance function synchronously in 50ms steps at mount (and after any resize) until the web lands solidly inside its first post-build rest window (3s into rest, well clear of the following tear), then paints exactly that one static, fully-built, undamaged frame and never schedules a rAF loop; a live change to the media query re-primes and re-freezes the same way. The canvas is aria-hidden with pointer-events implicitly none via absolute inset-0 with no listeners; children render as ordinary, independently focusable DOM above it. autoplay.mode is 'none' because the web animates entirely on its own internal clock with zero pointer/scroll/press dependency.

Props

PropTypeDefaultDescription
children?React.ReactNode——
className?string——
style?React.CSSProperties——