Effects & Borders

Metaball Blobs

Two to four soft blobs that drift on slow orbits and merge through an SVG goo filter for a restrained ambient surface accent.

Install

npx shadcn@latest add @paragon/metaball-blobs

metaball-blobs.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/**
 * MetaballBlobs — 2–4 soft blobs that drift on slow deterministic orbits and
 * merge through an SVG goo filter (blur + contrast threshold). Restrained and
 * enterprise: low contrast, slow motion, subtle — a living surface accent, not
 * a lava lamp.
 *
 * Motion is a single rAF loop that pauses offscreen (IntersectionObserver) and
 * halts under `prefers-reduced-motion` (or `static`), leaving a still frame.
 * Positions come from a deterministic PRNG, never Math.random at render.
 * Decorative: `aria-hidden`, `pointer-events-none`.
 */

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    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 hashString(input: string): number {
  let h = 0;
  for (let i = 0; i < input.length; i++)
    h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
  return h;
}

interface Blob {
  cx: number;
  cy: number;
  ax: number; // orbit radius x (fraction)
  ay: number;
  r: number; // blob radius (fraction of min dim)
  sp: number; // angular speed
  ph: number; // phase
}

export interface MetaballBlobsProps extends React.ComponentProps<"div"> {
  /** Blob color. */
  color?: string;
  /** Number of blobs, 2–4. */
  count?: number;
  /** Motion speed multiplier. */
  speed?: number;
  /** Base blob size, fraction of the smaller dimension. */
  size?: number;
  /** Layer opacity. */
  intensity?: number;
  /** Seed for deterministic placement. */
  seed?: number;
  /** Freeze motion. */
  static?: boolean;
}

export function MetaballBlobs({
  color = "var(--color-primary)",
  count = 3,
  speed = 1,
  size = 0.34,
  intensity = 0.5,
  seed,
  static: isStatic = false,
  className,
  style,
  ...props
}: MetaballBlobsProps) {
  const uid = React.useId().replace(/:/g, "");
  const filterId = `mb-${uid}`;
  const resolvedSeed = seed ?? hashString(uid);
  const n = Math.max(2, Math.min(4, Math.round(count)));

  const wrapRef = React.useRef<HTMLDivElement>(null);
  const groupRef = React.useRef<SVGGElement>(null);
  const circleRefs = React.useRef<(SVGCircleElement | null)[]>([]);

  const blobs = React.useMemo<Blob[]>(() => {
    const rand = mulberry32(resolvedSeed);
    return Array.from({ length: n }, (_, i) => ({
      cx: 0.3 + rand() * 0.4,
      cy: 0.3 + rand() * 0.4,
      ax: 0.12 + rand() * 0.14,
      ay: 0.1 + rand() * 0.14,
      r: size * (0.8 + rand() * 0.5),
      sp: (0.06 + rand() * 0.05) * (i % 2 ? -1 : 1),
      ph: rand() * Math.PI * 2,
    }));
  }, [resolvedSeed, n, size]);

  React.useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    let width = 0;
    let height = 0;
    let time = 0;

    const paint = () => {
      const m = Math.min(width, height);
      blobs.forEach((b, i) => {
        const c = circleRefs.current[i];
        if (!c) return;
        const x = (b.cx + Math.cos(time * b.sp + b.ph) * b.ax) * width;
        const y = (b.cy + Math.sin(time * b.sp * 1.3 + b.ph) * b.ay) * height;
        c.setAttribute("cx", String(x));
        c.setAttribute("cy", String(y));
        c.setAttribute("r", String(b.r * m));
      });
    };

    let rafId: number | null = null;
    let last = 0;
    const loop = (now: number) => {
      rafId = requestAnimationFrame(loop);
      const dt = Math.min(now - last, 60);
      last = now;
      time += (dt / 1000) * speed;
      paint();
    };

    let running = false;
    let inView = false;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");

    const sync = () => {
      const run = inView && !isStatic && !reduce.matches;
      if (run && !running) {
        running = true;
        last = performance.now();
        rafId = requestAnimationFrame(loop);
      } else if (!run && running) {
        running = false;
        if (rafId !== null) cancelAnimationFrame(rafId);
        rafId = null;
        paint();
      } else if (!run) {
        paint();
      }
    };

    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      width = rect.width;
      height = rect.height;
      paint();
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    const io = new IntersectionObserver(([e]) => {
      inView = e?.isIntersecting ?? false;
      sync();
    });
    io.observe(wrap);
    reduce.addEventListener("change", sync);

    return () => {
      ro.disconnect();
      io.disconnect();
      reduce.removeEventListener("change", sync);
      if (rafId !== null) cancelAnimationFrame(rafId);
    };
  }, [blobs, speed, isStatic]);

  return (
    <div
      ref={wrapRef}
      aria-hidden
      data-slot="metaball-blobs"
      className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)}
      style={{ opacity: intensity, ...style }}
      {...props}
    >
      <svg className="size-full" width="100%" height="100%">
        <defs>
          <filter id={filterId}>
            <feGaussianBlur in="SourceGraphic" stdDeviation="14" result="b" />
            <feColorMatrix
              in="b"
              type="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9"
            />
          </filter>
        </defs>
        <g ref={groupRef} filter={`url(#${filterId})`} fill={color}>
          {blobs.map((_, i) => (
            <circle
              key={i}
              ref={(el) => {
                circleRefs.current[i] = el;
              }}
              cx="0"
              cy="0"
              r="0"
            />
          ))}
        </g>
      </svg>
    </div>
  );
}