Skip to main content

ns-ui / loud

Brinicle Descent

A full-bleed underwater backdrop modelled on brinicles: dense, sub-zero brine sinking off newly formed sea ice freezes a hollow ice sheath around itself as it descends, flashes an expanding freeze halo where it touches bottom, then breaks free and drifts off while a fresh tip nucleates elsewhere. Several overlapping lifecycles running at once so the pane is never empty.

Usage

registry/loud/brinicle-descent/demo.tsx
"use client";

import { BrinicleDescent } from "./component";

// The tubes descend and freeze on their own; everything here is ordinary DOM
// type on tokens, sitting over the pane behind a scrim since the water wash
// spans the full value range in both themes.
export default function BrinicleDescentDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <BrinicleDescent>
        <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/70 px-7 py-5 backdrop-blur-md">
            <p className="font-mono text-[11px] uppercase tracking-[0.28em] text-ns-muted">
              ns-ui / brinicle-descent
            </p>
            <p className="max-w-sm text-sm text-foreground sm:text-base">
              Brine sinking off newly formed sea ice freezes a sheath around
              itself as it descends, then breaks free while a new tip
              nucleates elsewhere.
            </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>
      </BrinicleDescent>
    </main>
  );
}

Component

registry/loud/brinicle-descent/component.tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";

// ---------------------------------------------------------------------------
// BrinicleDescent — a full-bleed underwater backdrop grown from a real polar
// mechanic: brine rejected by newly forming sea ice sinks as a dense, cold
// plume, and because that plume sits at or below the surrounding water's
// freezing point a thin ice sheath nucleates and grows AROUND it, extending
// the plume downward as a hollow tube (a "brinicle" / ice stalactite —
// documented in Antarctic/Arctic time-lapse footage and brine-drainage-channel
// literature, e.g. Dayton & Martin 1971). Field/time-lapse descent rates
// cluster 0.3-1.5 m/hr; sheath radius grows diffusion-limited (Stefan-problem
// sqrt(t)) — thickest near the ceiling where a point has existed longest,
// thinnest at the active tip. On reaching the seafloor the plume spreads into
// an expanding freeze halo. A single downwelling site is transient: a tube
// thickens, eventually detaches from the ice above and drifts off, while new
// sites keep nucleating fresh tips elsewhere — never a single one-shot event.
//
// COMPRESSION: rendering ~2.5m of real water column, real descent at
// 0.3-1.5 m/hr would take 1.7-8.3 hours to cross this frame. That is
// compressed into an 8s on-screen descent (~1900-2900x real time) —
// disclosed here rather than hidden, since this is an illustrative ambient
// process, not a literal countdown.
//
// LIFECYCLE (per tube, a small state machine, several running staggered and
// overlapping so the pane is never empty): descend (8s, tip travels top to
// bottom) -> touchdown (a freeze halo expands/holds/fades at the seafloor,
// 4.4s total, while the sheath keeps thickening) -> detach (the ceiling
// attachment opens a gap, then the whole tube drifts down and fades, 1.9s) ->
// removed, while a NEW tip nucleates elsewhere 3-6s after the prior tube
// entered touchdown, so lifecycles overlap and the scene never resets empty.
// The pane is pre-seeded at mount with tubes already mid-lifecycle (negative
// phase-start offsets) rather than starting from a blank column, so t0 is
// already alive.
//
// SHEATH RADIUS: for a point at height y on a tube, depositElapsed is when
// the descending tip passed that y (linear in y along the 8s descent); its
// age is (current time since descent start) - depositElapsed, and radius is
// minR + (maxR-minR)*sqrt(clamp(age/8000, 0, 1)) — the ceiling end (oldest)
// saturates toward maxR while the tip stays thin, and the whole tube keeps
// visibly thickening for a while even after the tip stops moving.
//
// TOKENS AND THE THEME INVERSION: colors come only from --background,
// --foreground, --ns-muted, --border via getComputedStyle(documentElement),
// re-read on a MutationObserver watching its class. No manual "if dark
// theme" branch anywhere: the water wash is mix(background, muted, 0.3) and
// the tube core is mix(muted, foreground, 0.6) — because --foreground itself
// already flips (near-white ink in dark theme, near-black ink in light
// theme), mixing toward it automatically makes the tube read BRIGHT against
// dark water and DARK against pale water, the correct per-theme inversion,
// for free (the same trick dye-whorl uses, applied here without any explicit
// theme check). The tube's rim (its strongest edge) is always drawn in pure
// --foreground — the extreme ink value in whichever direction the theme
// points. --ns-accent never appears; the touchdown halo's climactic flash is
// pure luminance from --foreground.
//
// HOST: DPR-aware canvas sized off the host's own bounding box (never
// window.resize), capped at 2. ResizeObserver re-sizes; IntersectionObserver
// (threshold 0) and visibilitychange stop the single rAF loop when offscreen
// or hidden; MutationObserver re-reads tokens and, when the loop itself is
// stopped (reduced motion), repaints the frozen frame with the new colors.
// prefers-reduced-motion composes and draws exactly one still frame: one
// tube ~60% down its descent (thickened sheath, active tip) plus a second,
// younger tip freshly nucleated near the top — the most structurally
// complete single frame, never the near-empty literal t0.
// ---------------------------------------------------------------------------

type Phase = "descend" | "touchdown" | "detach";

interface Tube {
  id: number;
  x0: number; // nucleation x, 0..1 fraction of width
  phase: Phase;
  phaseStart: number; // engine clock ms when the CURRENT phase began
  descendStart: number; // engine clock ms when descent began (fixed for the tube's life)
  seedA: number; // wobble phase
  seedB: number; // reserved jitter
}

type RGB = [number, number, number];

const DESCEND_MS = 8000;
const HALO_EXPAND_MS = 1800;
const HALO_HOLD_MS = 600;
const HALO_FADE_MS = 2000;
const HALO_TOTAL_MS = HALO_EXPAND_MS + HALO_HOLD_MS + HALO_FADE_MS;
const DETACH_GAP_MS = 400;
const DETACH_DRIFT_MS = 1500;
const DETACH_TOTAL_MS = DETACH_GAP_MS + DETACH_DRIFT_MS;
const SPAWN_MIN_MS = 3000;
const SPAWN_MAX_MS = 6000;
const SAMPLES = 22;

function clamp01(v: number) {
  return Math.min(1, Math.max(0, v));
}
function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}
function easeOutCubic(t: number) {
  const u = 1 - t;
  return 1 - u * u * u;
}

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

function parseHex(v: string): RGB | null {
  const m = v.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
  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 readToken(el: HTMLElement, name: string, fallback: RGB): RGB {
  return parseHex(getComputedStyle(el).getPropertyValue(name)) ?? fallback;
}

function rgbaStr(rgb: RGB, a: number): string {
  return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${Math.max(0, a)})`;
}

function mixRgb(a: RGB, b: RGB, t: number): RGB {
  return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}

function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

function makeTube(id: number, rand: () => number, phaseStart: number): Tube {
  return {
    id,
    x0: lerp(0.12, 0.88, rand()),
    phase: "descend",
    phaseStart,
    descendStart: phaseStart,
    seedA: rand() * 1000,
    seedB: rand() * 1000,
  };
}

export interface BrinicleDescentProps {
  /** max concurrent brinicles, 2-4. @default 3 */
  density?: number;
  /** clock speed multiplier. @default 1 */
  speed?: number;
  /** freeze the simulation in place without unmounting it. @default false */
  paused?: boolean;
  children?: ReactNode;
  className?: string;
  style?: CSSProperties;
}

export function BrinicleDescent({
  density = 3,
  speed = 1,
  paused = false,
  children,
  className = "",
  style,
}: BrinicleDescentProps) {
  const reduced = useReducedMotion();
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const densityRef = useRef(density);
  densityRef.current = Math.round(Math.min(4, Math.max(2, density)));
  const speedRef = useRef(speed);
  speedRef.current = speed;
  const pausedRef = useRef(paused);
  pausedRef.current = paused;

  const engineRef = useRef({
    w: 0,
    h: 0,
    dpr: 1,
    clock: 0,
    nextSpawnAt: 4000,
    nextId: 1,
    tubes: [] as Tube[],
    raf: 0,
    lastNow: 0,
    visible: true,
    reduced: false,
    background: [10, 10, 10] as RGB,
    foreground: [237, 237, 237] as RGB,
    muted: [141, 141, 141] as RGB,
    rand: mulberry32(1),
  });

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    if (!host || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const e = engineRef.current;

    const syncColors = () => {
      const root = document.documentElement;
      e.background = readToken(root, "--background", e.background);
      e.foreground = readToken(root, "--foreground", e.foreground);
      e.muted = readToken(root, "--ns-muted", e.muted);
    };

    const resize = () => {
      const r = host.getBoundingClientRect();
      e.w = Math.max(1, r.width);
      e.h = Math.max(1, r.height);
      e.dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.round(e.w * e.dpr);
      canvas.height = Math.round(e.h * e.dpr);
      canvas.style.width = `${e.w}px`;
      canvas.style.height = `${e.h}px`;
    };

    // Draw one tube (sheath + rim) and, while in touchdown, its freeze halo.
    const drawTube = (tube: Tube, clock: number) => {
      const minDim = Math.min(e.w, e.h);
      const minR = minDim / 180;
      const maxR = minDim / 70;
      const topY = e.h * 0.06;
      const bottomY = e.h * 0.94;
      const columnH = bottomY - topY;
      const wobbleAmp = minDim * 0.018;
      const wobbleFreq = 0.018;

      const descendAge = clamp01((clock - tube.descendStart) / DESCEND_MS) * DESCEND_MS;
      const tipY = topY + columnH * (descendAge / DESCEND_MS);

      let topCut = topY;
      let dyOffset = 0;
      let globalAlpha = 1;

      if (tube.phase === "detach") {
        const age = clock - tube.phaseStart;
        if (age <= DETACH_GAP_MS) {
          const t = age / DETACH_GAP_MS;
          topCut = topY + columnH * 0.05 * t;
        } else {
          topCut = topY + columnH * 0.05;
          const t2 = clamp01((age - DETACH_GAP_MS) / DETACH_DRIFT_MS);
          dyOffset = 40 * t2;
          globalAlpha = 1 - t2;
        }
      }

      if (globalAlpha <= 0.002) return;

      const xAt = (y: number) => e.w * tube.x0 + wobbleAmp * Math.sin(y * wobbleFreq + tube.seedA);
      const radiusAt = (y: number) => {
        const depositElapsed = ((y - topY) / columnH) * DESCEND_MS;
        const age = clamp01((clock - tube.descendStart - depositElapsed) / DESCEND_MS) * DESCEND_MS;
        return minR + (maxR - minR) * Math.sqrt(age / DESCEND_MS);
      };

      const from = Math.min(topCut, tipY);
      const to = Math.max(topCut, tipY);
      if (to - from < 1) return;

      const pts: { x: number; y: number; r: number }[] = [];
      for (let i = 0; i <= SAMPLES; i++) {
        const y = lerp(from, to, i / SAMPLES);
        pts.push({ x: xAt(y), y: y + dyOffset, r: radiusAt(y) });
      }

      const core = mixRgb(e.muted, e.foreground, 0.6);
      ctx.fillStyle = rgbaStr(core, 0.85 * globalAlpha);
      ctx.beginPath();
      for (let i = 0; i < pts.length; i++) {
        const p = pts[i]!;
        if (i === 0) ctx.moveTo(p.x - p.r, p.y);
        else ctx.lineTo(p.x - p.r, p.y);
      }
      for (let i = pts.length - 1; i >= 0; i--) {
        const p = pts[i]!;
        ctx.lineTo(p.x + p.r, p.y);
      }
      ctx.closePath();
      ctx.fill();

      ctx.strokeStyle = rgbaStr(e.foreground, 0.45 * globalAlpha);
      ctx.lineWidth = 1;
      ctx.beginPath();
      pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x - p.r, p.y) : ctx.lineTo(p.x - p.r, p.y)));
      ctx.stroke();
      ctx.beginPath();
      pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x + p.r, p.y) : ctx.lineTo(p.x + p.r, p.y)));
      ctx.stroke();

      if (tube.phase === "touchdown") {
        const age = clock - tube.phaseStart;
        const tipX = xAt(bottomY);
        let r = 2;
        let a = 0;
        if (age < HALO_EXPAND_MS) {
          const t = age / HALO_EXPAND_MS;
          r = lerp(2, minDim * 0.15, easeOutCubic(t));
          a = 0.85 * t;
        } else if (age < HALO_EXPAND_MS + HALO_HOLD_MS) {
          r = minDim * 0.15;
          a = 0.85;
        } else {
          r = minDim * 0.15;
          const t = clamp01((age - HALO_EXPAND_MS - HALO_HOLD_MS) / HALO_FADE_MS);
          a = 0.85 * (1 - t);
        }
        if (a > 0.002) {
          const grad = ctx.createRadialGradient(tipX, bottomY, 0, tipX, bottomY, Math.max(1, r));
          grad.addColorStop(0, rgbaStr(e.foreground, a));
          grad.addColorStop(1, rgbaStr(e.foreground, 0));
          ctx.fillStyle = grad;
          ctx.beginPath();
          ctx.arc(tipX, bottomY, Math.max(1, r), 0, Math.PI * 2);
          ctx.fill();
        }
      }
    };

    const paint = (clock: number) => {
      ctx.setTransform(e.dpr, 0, 0, e.dpr, 0, 0);
      const water = mixRgb(e.background, e.muted, 0.3);
      ctx.fillStyle = rgbaStr(water, 1);
      ctx.fillRect(0, 0, e.w, e.h);
      for (const tube of e.tubes) drawTube(tube, clock);
    };

    const advance = (dtMs: number) => {
      e.clock += dtMs;
      const maxTubes = densityRef.current;

      for (const tube of e.tubes) {
        const age = e.clock - tube.phaseStart;
        if (tube.phase === "descend" && age >= DESCEND_MS) {
          tube.phase = "touchdown";
          tube.phaseStart = e.clock;
        } else if (tube.phase === "touchdown" && age >= HALO_TOTAL_MS) {
          tube.phase = "detach";
          tube.phaseStart = e.clock;
        }
      }
      e.tubes = e.tubes.filter((t) => !(t.phase === "detach" && e.clock - t.phaseStart >= DETACH_TOTAL_MS));

      if (e.tubes.length === 0) {
        e.tubes.push(makeTube(e.nextId++, e.rand, e.clock));
        e.nextSpawnAt = e.clock + lerp(SPAWN_MIN_MS, SPAWN_MAX_MS, e.rand());
      } else if (e.clock >= e.nextSpawnAt && e.tubes.length < maxTubes) {
        e.tubes.push(makeTube(e.nextId++, e.rand, e.clock));
        e.nextSpawnAt = e.clock + lerp(SPAWN_MIN_MS, SPAWN_MAX_MS, e.rand());
      }
    };

    const drawStatic = () => {
      // reduced motion: one composed still frame — one tube ~60% down its
      // descent (thickened sheath, active tip) plus a younger tip just
      // nucleated near the top. Built directly, never simulated forward.
      const rand = mulberry32(9001);
      const tubes: Tube[] = [
        { id: 1, x0: 0.34, phase: "descend", phaseStart: -DESCEND_MS * 0.6, descendStart: -DESCEND_MS * 0.6, seedA: rand() * 1000, seedB: 0 },
        { id: 2, x0: 0.68, phase: "descend", phaseStart: -300, descendStart: -300, seedA: rand() * 1000, seedB: 0 },
      ];
      const prevTubes = e.tubes;
      e.tubes = tubes;
      paint(0);
      e.tubes = prevTubes;
    };

    resize();
    syncColors();
    e.reduced = reduced;

    if (reduced) {
      drawStatic();
    } else {
      // pre-seed with tubes already mid-lifecycle so t0 is already alive,
      // never a blank column.
      const rand = mulberry32(7);
      e.rand = rand;
      e.clock = 0;
      e.tubes = [
        makeTube(e.nextId++, rand, -DESCEND_MS * 0.7),
        makeTube(e.nextId++, rand, -DESCEND_MS * 0.2),
      ];
      e.nextSpawnAt = lerp(SPAWN_MIN_MS, SPAWN_MAX_MS, rand());
      paint(e.clock);
    }

    const loop = (now: number) => {
      const dtMs = Math.min(48, now - e.lastNow);
      e.lastNow = now;
      if (!pausedRef.current) {
        advance(dtMs * speedRef.current);
      }
      paint(e.clock);
      e.raf = e.visible ? requestAnimationFrame(loop) : 0;
    };

    if (!reduced) {
      e.lastNow = performance.now();
      e.raf = requestAnimationFrame(loop);
    }

    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) drawStatic();
      else paint(e.clock);
    });
    ro.observe(host);

    const io = new IntersectionObserver(
      ([entry]) => {
        e.visible = !!entry?.isIntersecting;
        if (e.visible && !reduced && !e.raf) {
          e.lastNow = performance.now();
          e.raf = requestAnimationFrame(loop);
        } else if (!e.visible && e.raf) {
          cancelAnimationFrame(e.raf);
          e.raf = 0;
        }
      },
      { threshold: 0 }
    );
    io.observe(host);

    const onVisibility = () => {
      if (document.hidden && e.raf) {
        cancelAnimationFrame(e.raf);
        e.raf = 0;
      } else if (!document.hidden && e.visible && !reduced && !e.raf) {
        e.lastNow = performance.now();
        e.raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    const mo = new MutationObserver(() => {
      syncColors();
      if (reduced || !e.raf) {
        if (reduced) drawStatic();
        else paint(e.clock);
      }
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    return () => {
      cancelAnimationFrame(e.raf);
      e.raf = 0;
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [reduced]);

  return (
    <div ref={hostRef} className={`relative h-full w-full overflow-hidden ${className}`} style={style}>
      <canvas ref={canvasRef} aria-hidden role="presentation" className="pointer-events-none absolute inset-0 h-full w-full" />
      {children ? <div className="pointer-events-none absolute inset-0">{children}</div> : null}
    </div>
  );
}

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

Use when Pick brinicle-descent when the backdrop should be a slow, self-renewing growth-and-release process happening underwater — ice tubes descending, freezing a halo where they touch bottom, then breaking free while new ones start elsewhere, with no pointer input driving any of it. Pick rime-creep instead when the process should be dendritic frost crawling flat across a pane of glass on a fixed six-fold lattice with a pointer that melts it back; scroll-defrost when the frost/clarity transition should be driven by scroll progress rather than run ambiently; weld-pool when the surface itself should carry a headline rather than sit as pure backdrop.

Install

npx shadcn add https://design.helpmarq.com/r/brinicle-descent.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 <BrinicleDescent density? speed? paused? children? className? style?> as a full-bleed 2D-canvas underwater pane growing brinicles — brine-rejection ice tubes, not a heightfield or shader trick. REAL MECHANIC: sea ice forming above a water column rejects brine; the plume is colder/denser than the water below and sinks, and because it sits at or below the freezing point a thin ice sheath nucleates and grows around it as it descends, extending a hollow tube downward (documented Antarctic/Arctic time-lapse footage; brine-drainage-channel literature, e.g. Dayton & Martin 1971). Field/time-lapse descent rates cluster 0.3-1.5 m/hr. LIFECYCLE PER TUBE, several running staggered and overlapping (2-4 concurrent, controlled by `density`, default 3) so the scene is never empty: descend (8000ms — tip travels linearly from top to bottom of a column representing ~2.5m of water, an explicit ~1900-2900x compression of the real 1.7-8.3hr crossing, documented not hidden) -> touchdown (4400ms total: a freeze halo at the seafloor expands 1800ms via easeOutCubic to radius = 0.15 * min(w,h), holds 600ms at full alpha 0.85, fades 2000ms to zero, while the sheath keeps thickening) -> detach (1900ms: first 400ms opens a small gap at the ceiling attachment by nudging the tube's top cutoff down 5% of the column height, then over 1500ms the whole tube drifts down 40px while its alpha eases 1 to 0) -> removed. A new tip nucleates via `makeTube` at a fresh random x whenever live tube count is below `density` and an engine-clock timer (jittered 3000-6000ms after the last spawn) elapses, or immediately if the tube list ever empties (a hard guarantee against a blank frame). The pane is PRE-SEEDED at mount with two tubes already mid-lifecycle (negative phase-start offsets relative to the engine clock, not simulated forward) so t0 is already alive rather than a blank column filling in. SHEATH RADIUS: for a sampled point at height y, depositElapsed = (y-topY)/columnHeight * 8000 is when the descending tip passed that y; that point's age is clamp(engineClock - tube.descendStart - depositElapsed, 0, 8000), and radius = minR + (maxR-minR)*sqrt(age/8000), with minR/maxR derived from the container's smaller dimension (/180 and /70) — the ceiling end (oldest) saturates toward maxR while the tip stays thin, and the whole tube keeps visibly thickening even once its tip has stopped moving. Each tube also carries a gentle sinusoidal x-wobble (amplitude ~1.8% of the smaller dimension, seeded per tube) so tubes read as organic descending plumes, not rigid lines. RENDERING: one 2D canvas, `w-full h-full`, geometry entirely derived from the host's own bounding box (never window size), redrawn every frame by filling a water wash then, per live tube, a filled quad-strip polygon (sampled at 22 points from cutoff to tip, offset left/right by radiusAt(y)) plus thin rim strokes down both edges, plus a radial-gradient touchdown halo when in that phase. TOKENS, NO MANUAL THEME BRANCH: colors read only from --background, --foreground, --ns-muted via getComputedStyle(document.documentElement), re-read on a MutationObserver watching its class, with no paint before that first read. Water wash = mix(background, muted, 0.3). Tube core = mix(muted, foreground, 0.6). Tube rim and the touchdown halo are pure foreground. Because --foreground itself already flips (near-white ink in dark theme, near-black ink in light theme), mixing toward it makes the tube automatically read bright against dark water in dark theme and dark against pale water in light theme — the correct per-theme value inversion with zero explicit theme detection, the same trick dye-whorl uses. --ns-accent never appears anywhere in the render. HOST: DPR-aware canvas capped at 2, sized off a ResizeObserver on the host div. A single rAF loop (engine-clock driven, dt capped at 48ms and scaled by `speed`) is stopped by an IntersectionObserver (threshold 0) when scrolled offscreen and by `visibilitychange` when the tab is hidden, and restarted cleanly on return. `paused` freezes the engine clock in place (loop keeps repainting the same instant) without unmounting anything. prefers-reduced-motion composes and draws exactly ONE still frame, built directly rather than simulated forward: one tube 60% down its descent (thickened sheath, active tip) plus a second, younger tip freshly nucleated near the top — the most structurally complete single frame, never the near-empty literal t0 — and the MutationObserver still repaints that frozen frame on a theme change. The canvas is aria-hidden/role=presentation; children render in an absolutely-positioned overlay above it and need a token scrim (bg-background/70 + backdrop-blur) since the water wash spans the full value range in both themes.

Props

PropTypeDefaultDescription
density?number3max concurrent brinicles, 2-4. @default 3
speed?number1clock speed multiplier. @default 1
paused?booleanfalsefreeze the simulation in place without unmounting it. @default false
children?ReactNode——
className?string——
style?CSSProperties——