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.

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.

Source
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>
  );
}
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