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.

Usage

registry/loud/bed-fluidize/demo.tsx
"use client";

import { BedFluidize } from "./component";

// The bed is already boiling before anything is touched — bubbles nucleate,
// grow and burst on their own, forever. Hover to locally raise the gas rate
// and pull nucleation toward the cursor, the same local-maldistribution
// behavior a real fluidized-bed operator sees under a disturbance.
export default function BedFluidizeDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <BedFluidize>
        <div className="pointer-events-none flex h-full w-full flex-col items-center justify-center gap-4 px-6 text-center">
          <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 / bed-fluidize
            </p>
            <h1 className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
              A bed that never settles.
            </h1>
            <p className="max-w-sm text-sm text-ns-muted">
              Gas voids nucleate at the floor, grow as they climb, merge, and
              burst at the surface — the bed keeps boiling with no input.
            </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>
      </BedFluidize>
    </main>
  );
}

Component

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

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

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.

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——