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.

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.

Source
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";
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