Skip to main content

ns-ui / loud

Bed Fluidize

A full-bleed ambient background modeling a gas-fluidized particle bed: voids nucleate at the distributor plate, grow with height (the Darton bubble-growth relation), coalesce when they touch, and burst at the surface with a ballistic spray of ejected particles, while the surrounding bed circulates downward in each bubble's wake. A standing boil with no saturated end state.

Use when Pick bed-fluidize for a backdrop that should read as a continuously boiling granular surface — discrete voids nucleating, growing, merging and bursting with a ballistic spray, never settling. Pick background-capillary-wick instead when the backdrop should read as a FIXED lattice that ink slowly finds its way through (discrete fronts stalling and branching along a graph, no bubbles, no particle field); pick dye-whorl instead for open, cursor-stirrable fluid turbulence with no discrete bubble or particle structure at all.

Install

npx shadcn add https://design.helpmarq.com/r/bed-fluidize.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/bed-fluidize/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// BedFluidize — a full-bleed ambient background modeling a gas-fluidized
// particle bed: gas rising through a distributor plate suspends a bed of
// solid granules so it behaves like a boiling liquid. Voids ("bubbles")
// nucleate at the distributor, GROW as they climb (diameter ∝ height^0.4,
// the Darton bubble-growth relation used for real bubbling fluidized-bed
// reactors), COALESCE when two touch, and BURST at the bed surface,
// ejecting a few particles on a ballistic arc into the freeboard above.
// Particles caught in a rising bubble's wake drift down and brighten
// (circulation), matching the real observation that bed solids trail
// bubbles rather than free-falling independently.
//
// This is deliberately NOT a Navier-Stokes fluid field (dye-whorl) and NOT
// a fixed-lattice front-propagation graph (background-capillary-wick): the
// bed is a dense but ordinary particle grid, and the only moving primitives
// are a small population of bubbles (SDF circles) that grow, merge and pop.
// Particles never have persistent velocity state of their own — their
// on-screen offset and brightness are read live off the nearest bubble each
// frame, which keeps the whole sim O(particles * active_bubbles) with a
// bubble population capped low (~40) rather than needing a real N-body pass.
//
// ALIVE AT REST BY CONSTRUCTION: nucleation is a Poisson process that never
// stops, so there is no saturated end state — every bubble present at mount
// has burst and been replaced within a few seconds, forever.
//
// Tokens: --background clears the canvas and IS the void interior (a
// bubble is drawn by simply not drawing particles inside it, plus a thin
// --ns-muted rim so it still reads as a delineated void rather than a gap
// in the field). --ns-muted is the resting particle color; particles
// brighten toward --foreground only while inside a bubble's wake band or
// under active ejecta/pointer agitation. --ns-accent never appears — a
// resting bed has no interaction chrome to speak of.
// ---------------------------------------------------------------------------

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

interface Bubble {
  x: number;
  cy: number; // center y, smaller = higher (closer to bed surface)
  spawnCy: number;
  d: number; // current diameter, px
}

interface Crater {
  x: number;
  y: number;
  r: number;
  born: number; // sim time
}

interface Ejecta {
  x: number;
  y0: number;
  vx: number;
  vy0: number;
  born: number;
  life: number; // ms
}

const D0_FRAC = 0.03; // nucleation diameter, fraction of container height
const DMAX_FRAC = 0.22; // burst-eligible cap, fraction of container height
const GROWTH_EXP = 0.4; // Darton bubble-growth exponent
const NUCLEATE_RATE_PER_100PX = 2.2; // bubbles/s per 100px of container width
const WAKE_BAND_FACTOR = 1.55; // wake band radius = bubble radius * this
const WAKE_DRAG = 0.6; // particle wake drift = 0.6 * local bubble rise speed
const MID_CROSS_S = 3.2; // s for a diameter=DMAX*0.5 bubble to cross the bed
const CRATER_FADE_MS = 480;
const EJECTA_GRAVITY = 2600; // px/s^2
const EJECTA_MIN_MS = 220;
const EJECTA_MAX_MS = 380;
const FREEBOARD_FRAC = 0.1; // fraction of container height reserved above the bed
const MAX_PARTICLES = 6000;
const MAX_BUBBLES = 40;
const POINTER_RADIUS_FACTOR = 0.18; // of min(width,height)
const POINTER_DECAY_MS = 600;
const WARM_STEPS = 220;
const WARM_DT = 1 / 30;

export interface BedFluidizeProps {
  /** particle grid pitch, fraction of the container's smaller dimension. @default 1/60 */
  pitchRatio?: number;
  /** freeze the field at its warm-start frame. @default false */
  paused?: boolean;
  children?: ReactNode;
  className?: string;
  style?: CSSProperties;
}

export function BedFluidize({
  pitchRatio = 1 / 60,
  paused = false,
  children,
  className = "",
  style,
}: BedFluidizeProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

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

    // token fields start empty and are only ever assigned from
    // getComputedStyle — nothing here has a literal color fallback. Every
    // path that could paint (ResizeObserver, IntersectionObserver, the
    // reduced-motion branch) is gated behind `ready`, set only after the
    // first token read.
    let bg = "";
    let muted = "";
    let fg = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      bg = cs.getPropertyValue("--background").trim();
      muted = cs.getPropertyValue("--ns-muted").trim();
      fg = cs.getPropertyValue("--foreground").trim();
    };

    let dpr = 1;
    let width = 0;
    let height = 0;
    let bedTopY = 0;
    let bedBottomY = 0;
    let bedHeightPx = 0;
    let d0Px = 0;
    let dMaxPx = 0;
    let growthK = 0;
    let riseCoeff = 0;
    let nucleateRate = 0; // bubbles/s
    let cellPx = 12;
    let sized = false;
    let ready = false;
    let disposed = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let simTime = 0;
    let nextNucleate = 0;

    const rand = mulberry32(0x51ed270b);

    let baseX: Float32Array = new Float32Array(0);
    let baseY: Float32Array = new Float32Array(0);
    let particleCount = 0;

    let bubbles: Bubble[] = [];
    let craters: Crater[] = [];
    let ejecta: Ejecta[] = [];

    let pointerActive = false;
    let pointerX = 0;
    let pointerY = 0;
    let pointerBoost = 0; // 0..1, eases toward pointerActive target

    const buildField = () => {
      const minDim = Math.min(width, height);
      bedTopY = height * FREEBOARD_FRAC;
      bedBottomY = height * 0.98;
      bedHeightPx = Math.max(1, bedBottomY - bedTopY);
      d0Px = Math.max(2, height * D0_FRAC);
      dMaxPx = Math.max(d0Px + 1, height * DMAX_FRAC);
      growthK = (dMaxPx - d0Px) / Math.pow(bedHeightPx, GROWTH_EXP);
      const midD = dMaxPx * 0.5;
      const midRise = bedHeightPx / MID_CROSS_S;
      riseCoeff = midRise / Math.sqrt(midD);
      nucleateRate = (width / 100) * NUCLEATE_RATE_PER_100PX;

      cellPx = Math.max(6, minDim * pitchRatio);
      let cols = Math.max(4, Math.ceil(width / cellPx));
      let rows = Math.max(4, Math.ceil(bedHeightPx / cellPx));
      if (cols * rows > MAX_PARTICLES) {
        const scale = Math.sqrt((cols * rows) / MAX_PARTICLES);
        cellPx *= scale;
        cols = Math.max(4, Math.ceil(width / cellPx));
        rows = Math.max(4, Math.ceil(bedHeightPx / cellPx));
      }
      particleCount = cols * rows;
      baseX = new Float32Array(particleCount);
      baseY = new Float32Array(particleCount);
      let i = 0;
      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          const jx = (rand() - 0.5) * cellPx * 0.6;
          const jy = (rand() - 0.5) * cellPx * 0.6;
          baseX[i] = c * cellPx + cellPx / 2 + jx;
          baseY[i] = bedTopY + r * cellPx + cellPx / 2 + jy;
          i++;
        }
      }

      bubbles = [];
      craters = [];
      ejecta = [];
      simTime = 0;
      nextNucleate = 0;
    };

    const nucleate = () => {
      if (bubbles.length >= MAX_BUBBLES) return;
      let x = rand() * width;
      // pointer locally raises the odds a fresh bubble nucleates near it —
      // a real fluidized-bed behavior (local gas maldistribution under a
      // disturbance), not a cosmetic pointer trail.
      if (pointerBoost > 0.05 && rand() < pointerBoost * 0.7) {
        const radius = Math.min(width, height) * POINTER_RADIUS_FACTOR;
        x = pointerX + (rand() - 0.5) * radius * 2;
        x = Math.max(0, Math.min(width, x));
      }
      bubbles.push({ x, cy: bedBottomY, spawnCy: bedBottomY, d: d0Px });
    };

    const burst = (b: Bubble) => {
      craters.push({ x: b.x, y: bedTopY, r: b.d * 0.5, born: simTime });
      const n = 3 + Math.floor(rand() * 4); // 3..6
      for (let k = 0; k < n; k++) {
        const life = EJECTA_MIN_MS + rand() * (EJECTA_MAX_MS - EJECTA_MIN_MS);
        const vx = (rand() - 0.5) * (b.d * 2.2);
        const vy0 = (EJECTA_GRAVITY * (life / 1000)) / 2; // returns to y0 at t=life
        ejecta.push({
          x: b.x + (rand() - 0.5) * b.d * 0.4,
          y0: bedTopY,
          vx,
          vy0,
          born: simTime,
          life,
        });
      }
    };

    const step = (dt: number) => {
      simTime += dt * 1000;
      const dtS = dt;

      if (!paused) {
        pointerBoost += ((pointerActive ? 1 : 0) - pointerBoost) * Math.min(1, dt * (1000 / POINTER_DECAY_MS) * 2.2);
      }

      const rateNow = nucleateRate * (1 + pointerBoost * 0.5);
      nextNucleate -= dtS * rateNow;
      while (nextNucleate <= 0) {
        nucleate();
        // exponential inter-arrival keeps this a genuine Poisson process
        // rather than a fixed metronome tick
        nextNucleate += Math.max(0.05, -Math.log(1 - rand()));
      }

      for (const b of bubbles) {
        const climbed = Math.max(0, b.spawnCy - b.cy);
        b.d = Math.min(dMaxPx, d0Px + growthK * Math.pow(climbed, GROWTH_EXP));
        const riseSpeed = riseCoeff * Math.sqrt(b.d);
        b.cy -= riseSpeed * dtS;
      }

      // coalescence: touching bubbles merge into one of combined area
      for (let i = 0; i < bubbles.length; i++) {
        for (let j = bubbles.length - 1; j > i; j--) {
          const a = bubbles[i];
          const c = bubbles[j];
          const dx = a.x - c.x;
          const dy = a.cy - c.cy;
          const dist = Math.hypot(dx, dy);
          if (dist < (a.d + c.d) * 0.42) {
            const areaA = a.d * a.d;
            const areaC = c.d * c.d;
            const total = areaA + areaC;
            a.x = (a.x * areaA + c.x * areaC) / total;
            a.cy = Math.min(a.cy, c.cy); // leading (higher) edge wins
            a.spawnCy = Math.max(a.spawnCy, c.spawnCy);
            a.d = Math.sqrt(areaA + areaC);
            bubbles.splice(j, 1);
          }
        }
      }

      bubbles = bubbles.filter((b) => {
        if (b.cy - b.d / 2 <= bedTopY) {
          burst(b);
          return false;
        }
        return true;
      });

      craters = craters.filter((c) => simTime - c.born < CRATER_FADE_MS);
      ejecta = ejecta.filter((e) => simTime - e.born < e.life);
    };

    const particleColorAt = (px: number, py: number) => {
      // returns null if the particle sits inside a bubble void (not drawn),
      // otherwise { alpha, mix } where mix 0=muted 1=foreground
      let mix = 0;
      for (let bi = 0; bi < bubbles.length; bi++) {
        const b = bubbles[bi];
        if (Math.abs(px - b.x) > b.d * WAKE_BAND_FACTOR) continue;
        const dx = px - b.x;
        const dy = py - b.cy;
        const dist = Math.hypot(dx, dy);
        const r = b.d / 2;
        if (dist <= r) return null; // inside the void itself
        const wakeR = r * WAKE_BAND_FACTOR;
        if (dist < wakeR) {
          const t = 1 - (dist - r) / (wakeR - r);
          mix = Math.max(mix, t);
        }
      }
      if (pointerBoost > 0.02) {
        const radius = Math.min(width, height) * POINTER_RADIUS_FACTOR;
        const dist = Math.hypot(px - pointerX, py - pointerY);
        if (dist < radius) {
          mix = Math.max(mix, pointerBoost * (1 - dist / radius) * 0.8);
        }
      }
      return { mix };
    };

    const draw = () => {
      if (!sized) return;
      ctx.fillStyle = bg;
      ctx.fillRect(0, 0, width, height);

      // distributor plate baseline
      ctx.strokeStyle = muted;
      ctx.globalAlpha = 0.35;
      ctx.lineWidth = Math.max(1, height * 0.004);
      ctx.beginPath();
      ctx.moveTo(0, bedBottomY);
      ctx.lineTo(width, bedBottomY);
      ctx.stroke();

      const dotR = Math.max(0.9, cellPx * 0.16);
      for (let i = 0; i < particleCount; i++) {
        const px = baseX[i];
        let py = baseY[i];
        const c = particleColorAt(px, py);
        if (!c) continue;
        // wake circulation: particles near a bubble drift down and brighten
        for (let bi = 0; bi < bubbles.length; bi++) {
          const b = bubbles[bi];
          if (Math.abs(px - b.x) > b.d * WAKE_BAND_FACTOR) continue;
          const r = b.d / 2;
          const dist = Math.hypot(px - b.x, py - b.cy);
          const wakeR = r * WAKE_BAND_FACTOR;
          if (dist >= r && dist < wakeR) {
            const riseSpeed = riseCoeff * Math.sqrt(b.d);
            const t = 1 - (dist - r) / (wakeR - r);
            py += t * WAKE_DRAG * riseSpeed * 0.05;
          }
        }
        ctx.globalAlpha = 0.4 + 0.6 * c.mix;
        ctx.fillStyle = c.mix > 0.5 ? fg : muted;
        ctx.beginPath();
        ctx.arc(px, py, dotR, 0, Math.PI * 2);
        ctx.fill();
      }

      // bubble rims — the void interior is just cleared background, the rim
      // is what keeps it legible as a delineated bubble rather than a hole
      // in the particle field
      ctx.strokeStyle = muted;
      ctx.lineWidth = Math.max(1, cellPx * 0.12);
      for (const b of bubbles) {
        ctx.globalAlpha = 0.5;
        ctx.beginPath();
        ctx.arc(b.x, b.cy, b.d / 2, 0, Math.PI * 2);
        ctx.stroke();
      }

      // bursting craters: fading rim at the bed surface
      for (const c of craters) {
        const age = simTime - c.born;
        const t = 1 - age / CRATER_FADE_MS;
        if (t <= 0) continue;
        ctx.globalAlpha = t * 0.4;
        ctx.strokeStyle = muted;
        ctx.lineWidth = Math.max(1, cellPx * 0.1);
        ctx.beginPath();
        ctx.arc(c.x, c.y, c.r * (1 + (1 - t) * 0.8), 0, Math.PI * 2);
        ctx.stroke();
      }

      // ejecta: ballistic specks under constant downward acceleration
      ctx.fillStyle = fg;
      for (const e of ejecta) {
        const age = (simTime - e.born) / 1000;
        const x = e.x + e.vx * age;
        const y = e.y0 - e.vy0 * age + 0.5 * EJECTA_GRAVITY * age * age;
        if (y > height) continue;
        const lifeT = 1 - (simTime - e.born) / e.life;
        ctx.globalAlpha = Math.max(0, lifeT) * 0.9;
        ctx.beginPath();
        ctx.arc(x, y, dotR * 1.1, 0, Math.PI * 2);
        ctx.fill();
      }

      ctx.globalAlpha = 1;
    };

    // reduced-motion / paused: one deliberately-chosen static frame showing
    // nucleation, growth and a fresh burst all at once — never the bare
    // t=0 flat-floor state. Named FREEZE_PHASE = mid-rise-with-recent-burst.
    const drawStaticFreeze = () => {
      if (!sized) return;
      bubbles = [
        { x: width * 0.18, cy: bedBottomY - bedHeightPx * 0.08, spawnCy: bedBottomY, d: d0Px * 1.3 },
        { x: width * 0.5, cy: bedBottomY - bedHeightPx * 0.6, spawnCy: bedBottomY, d: dMaxPx * 0.55 },
        { x: width * 0.72, cy: bedBottomY - bedHeightPx * 0.32, spawnCy: bedBottomY, d: dMaxPx * 0.32 },
        { x: width * 0.85, cy: bedBottomY - bedHeightPx * 0.15, spawnCy: bedBottomY, d: d0Px * 1.8 },
      ];
      craters = [{ x: width * 0.36, y: bedTopY, r: dMaxPx * 0.3, born: CRATER_FADE_MS * 0.4 }];
      simTime = CRATER_FADE_MS * 0.4;
      ejecta = [];
      pointerBoost = 0;
      draw();
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      const w = Math.round(rect.width);
      const h = Math.round(rect.height);
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      width = w;
      height = h;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(width * dpr));
      canvas.height = Math.max(1, Math.round(height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      buildField();
      sized = true;
    };

    const warmStart = () => {
      for (let i = 0; i < WARM_STEPS; i++) step(WARM_DT);
    };

    const loop = (now: number) => {
      if (!visible) return;
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      step(dt);
      draw();
      raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (!sized) return;
        if (reduced || paused) {
          drawStaticFreeze();
        } else {
          warmStart();
          ready = true;
          draw();
          if (visible && !raf) {
            last = 0;
            raf = requestAnimationFrame(loop);
          }
        }
      }, 150);
    };

    const ro = new ResizeObserver(onResize);
    ro.observe(root);

    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? true;
        if (visible && ready && !reduced && !paused) {
          last = 0;
          raf = requestAnimationFrame(loop);
        } else {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { threshold: 0 }
    );
    io.observe(root);

    const onVis = () => {
      if (document.hidden) {
        cancelAnimationFrame(raf);
        raf = 0;
      } else if (visible && ready && !reduced && !paused) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced || paused) drawStaticFreeze();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      const rect = root.getBoundingClientRect();
      pointerX = e.clientX - rect.left;
      pointerY = e.clientY - rect.top;
      pointerActive = true;
    };
    const onLeave = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      pointerActive = false;
    };
    if (!reduced) {
      root.addEventListener("pointermove", onMove);
      root.addEventListener("pointerleave", onLeave);
    }

    document.fonts.ready.then(() => {
      if (disposed) return;
      readTokens();
      resize();
      if (!sized) {
        ready = true;
        return;
      }
      if (reduced || paused) {
        drawStaticFreeze();
        ready = true;
      } else {
        warmStart();
        ready = true;
        draw();
        raf = requestAnimationFrame(loop);
      }
    });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      root.removeEventListener("pointermove", onMove);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, [pitchRatio, paused]);

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

BedFluidize.displayName = "BedFluidize";
Build spec

Build <BedFluidize pitchRatio? paused? children? className? style?> as a full-bleed Canvas 2D background modeling a REAL GAS-FLUIDIZED PARTICLE BED, not generic noise churn or a fluid turbulence field. LAYOUT: the container height splits into a bed region (bottom ~90%, bedTopY..bedBottomY) and a freeboard region above it (top ~10%) where burst ejecta fly and fall; a thin --ns-muted baseline at bedBottomY reads as the gas distributor plate. PARTICLES: a jittered grid of dots fills the bed region, generated once per container size (base positions in two Float32Arrays, never reshuffled), sized so the total count never exceeds 6000 — pitch is a fraction of the container's smaller dimension (default 1/60), scaled up automatically if the raw grid would exceed the cap. Particles carry NO persistent velocity state: every frame, each particle's visibility and color are read live off the current bubble list, which is what keeps the whole sim cheap despite a dense grid. BUBBLES: a small population (array, hard-capped at 40) of circles. Nucleation is a genuine Poisson process — rate = (containerWidth/100) * 2.2 bubbles/s at rest, exponential inter-arrival via -log(1-rand()), a fresh bubble spawning at a uniform-random x along the distributor with the container-height-derived starting diameter (3% of height). GROWTH: diameter = d0 + k * heightClimbed^0.4 (the Darton relation — climbed = distance risen from the bubble's own spawn point, not from t=0), k derived once per resize so a bubble would reach the 22%-of-height burst cap over a full bed traverse; diameter is clamped at that cap regardless. RISE: rise speed = riseCoeff * sqrt(diameter), riseCoeff calibrated per resize so a diameter = cap*0.5 bubble crosses the full bed height in 3.2s — bigger bubbles measurably outrun smaller ones, matching the real sqrt(d) relation. COALESCENCE: every step, any two bubbles whose centers are closer than 0.42 * (sum of diameters) merge into one bubble of combined AREA (d_new = sqrt(d1^2+d2^2)), keeping the higher (smaller-y) center and the larger accumulated climb distance so growth continues correctly post-merge. BURST: when a bubble's top edge reaches bedTopY it is removed, a fading crater rim is added at the surface (~480ms fade), and 3-6 ejecta specks are spawned with a real ballistic trajectory under a constant 2600px/s^2 downward acceleration, each given a randomized 220-380ms flight time and an initial upward velocity solved so the parabola returns to its launch height exactly at that flight time (vy0 = g*life/2) — this is what makes the ejecta arc and fall back rather than just fading in place. RENDERING: a particle inside a bubble's radius is simply not drawn (the void interior is the cleared --background, not a separately colored fill) — a thin --ns-muted stroke traces every bubble's rim so the void still reads as delineated rather than a hole in the field. Particles within a wake band (radius = bubble radius * 1.55, outside the bubble itself) get a small downward positional offset scaled by local rise speed (the WAKE_DRAG constant, 0.6) AND a brightness lift toward --foreground — real fluidized-bed circulation, solids trailing bubbles' wakes rather than free-falling. ALIVE AT REST BY CONSTRUCTION: because nucleation never stops and every bubble present at any instant is guaranteed to burst and be replaced within a few seconds (bed traversal ~2.5-4s depending on size), there is no saturated end state to settle into — the t0/2.5s/5s checkpoints are structurally guaranteed to differ, not just phase-shifted, since no bubble present at t0 can still exist unburst by t5s. INTERACTION: pointer position (root pointermove/pointerleave, skipped for touch pointers) locally raises nucleation odds and particle brightness within a radius (18% of min(width,height)) via a pointerBoost scalar that eases toward its target and decays back over ~600ms after the pointer leaves — never a fixed accent-colored highlight, purely a rate/luminance change, and it never tints anything with --ns-accent. TOKENS: --background clears the canvas and is the void interior; --ns-muted is the resting particle/rim/distributor color; --foreground is the peak-agitation particle and ejecta color. All three are read via getComputedStyle(document.documentElement) only inside document.fonts.ready, before the first paint, and re-read on a MutationObserver watching documentElement's class, with every early-paint path (ResizeObserver, IntersectionObserver, reduced-motion) gated behind a `ready` flag. WARM START: 220 fixed-dt (1/30s) steps run before the very first paint so the field already shows several bubbles at different growth stages rather than a bare distributor plate. prefers-reduced-motion (and the paused prop) render one deliberately-authored static frame — FREEZE_PHASE = mid-rise-with-recent-burst: four bubbles at staggered heights including one at 60% of the bed's vertical span, plus a still-fading crater near the surface — chosen because it is the single frame that shows nucleation, growth and a recent burst simultaneously, never the bare t=0 floor. HOST: DPR-capped backing store (max 2), ResizeObserver on the root rebuilds the particle grid and bubble-growth constants on layout change, IntersectionObserver (threshold 0) and visibilitychange both stop the single rAF loop when offscreen/hidden. The canvas is aria-hidden and pointer-events-none; children render as real DOM above it in normal focus order.

Props

PropTypeDefaultDescription
pitchRatio?number1 / 60particle grid pitch, fraction of the container's smaller dimension. @default 1/60
paused?booleanfalsefreeze the field at its warm-start frame. @default false
children?ReactNode
className?string
style?CSSProperties