Two to four soft blobs that drift on slow orbits and merge through an SVG goo filter for a restrained ambient surface accent.
npx shadcn@latest add @paragon/metaball-blobs"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>
);
}