Workspace / component / animations
Meta Balls
paxfx add blocks/component-animations-meta-ballslocal onlyTier: high. Reduced motion: Blobs rest in a static cluster.
"use client";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";
export interface MetaBallsProps {
/** Number of blobs (2 to 8). */
count?: number;
/** Blob diameter in pixels for the largest blob. */
size?: number;
className?: string;
children?: ReactNode;
}
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;
};
}
/**
* Gooey acid blobs chase the pointer and merge on contact. The goo is an
* SVG filter: a heavy gaussian blur followed by an alpha contrast step in
* `feColorMatrix`, so overlapping blobs fuse into one surface. Blobs are
* plain divs positioned imperatively in a single rAF loop with staggered
* easing; with no pointer they drift on deterministic orbits. Reduced
* motion rests the cluster in the center, fully merged and still.
*/
export function MetaBalls({ count = 5, size = 120, className, children }: MetaBallsProps) {
const reduced = useReducedMotion();
const filterId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [rootRef, inView] = useInView<HTMLDivElement>({ threshold: 0.15 });
const blobsRef = useRef<(HTMLDivElement | null)[]>([]);
const blobCount = Math.min(8, Math.max(2, Math.round(count)));
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const rand = mulberry32(0xc0ffee);
const params = Array.from({ length: blobCount }, (_, i) => ({
ease: 0.045 + i * 0.028,
orbitR: 30 + rand() * 60,
orbitSpeed: (0.3 + rand() * 0.5) * (i % 2 === 0 ? 1 : -1),
phase: rand() * Math.PI * 2,
x: 0,
y: 0,
}));
const rect = () => root.getBoundingClientRect();
const center = () => {
const r = rect();
return { x: r.width / 2, y: r.height / 2 };
};
const place = () => {
for (let i = 0; i < blobCount; i += 1) {
const blob = blobsRef.current[i];
const p = params[i];
if (!blob || !p) continue;
blob.style.transform = `translate3d(${p.x}px, ${p.y}px, 0) translate(-50%, -50%)`;
}
};
if (reduced) {
const c = center();
const rest = mulberry32(0xbeef);
for (const p of params) {
p.x = c.x + (rest() - 0.5) * 70;
p.y = c.y + (rest() - 0.5) * 44;
}
place();
return;
}
if (!inView) {
const c = center();
for (const p of params) {
p.x = c.x;
p.y = c.y;
}
place();
return;
}
const pointer = { ...center(), inside: false };
for (const p of params) {
p.x = pointer.x;
p.y = pointer.y;
}
let raf = 0;
let start = performance.now();
let elapsed = 0;
const frame = (now: number) => {
elapsed = (now - start) / 1000;
const c = center();
for (let i = 0; i < blobCount; i += 1) {
const p = params[i];
if (!p) continue;
const angle = p.phase + elapsed * p.orbitSpeed;
const tx = pointer.inside
? pointer.x + Math.cos(angle) * p.orbitR * 0.35
: c.x + Math.cos(angle) * p.orbitR;
const ty = pointer.inside
? pointer.y + Math.sin(angle) * p.orbitR * 0.35
: c.y + Math.sin(angle) * p.orbitR * 0.6;
p.x += (tx - p.x) * p.ease * (pointer.inside ? 2 : 1);
p.y += (ty - p.y) * p.ease * (pointer.inside ? 2 : 1);
}
place();
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const play = () => {
if (!raf && !document.hidden) {
start = performance.now() - elapsed * 1000;
raf = requestAnimationFrame(frame);
}
};
const onVisibility = () => (document.hidden ? stop() : play());
const onMove = (event: PointerEvent) => {
const r = rect();
pointer.x = event.clientX - r.left;
pointer.y = event.clientY - r.top;
pointer.inside = true;
};
const onLeave = () => {
pointer.inside = false;
};
play();
root.addEventListener("pointermove", onMove);
root.addEventListener("pointerleave", onLeave);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
root.removeEventListener("pointermove", onMove);
root.removeEventListener("pointerleave", onLeave);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [rootRef, blobCount, reduced, inView]);
return (
<div
ref={rootRef}
className={cn(
"relative isolate min-h-[280px] overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
"bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
className,
)}
data-pax-asset="component-animations-meta-balls"
>
<svg aria-hidden="true" className="absolute h-0 w-0">
<defs>
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="14" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 26 -12"
/>
</filter>
</defs>
</svg>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1]"
style={{ filter: `url(#${filterId})` }}
>
{Array.from({ length: blobCount }, (_, i) => (
<div
key={i}
ref={(node) => {
blobsRef.current[i] = node;
}}
className="absolute left-0 top-0 rounded-full bg-[var(--pax-acid,#c8ff00)] will-change-transform"
style={{
width: size * (1 - (i % 3) * 0.22),
height: size * (1 - (i % 3) * 0.22),
opacity: 0.92,
}}
/>
))}
</div>
<div className="pointer-events-none relative z-[2]">
{children ?? (
<div className="flex min-h-[280px] flex-col justify-end p-8">
<p className="m-0 text-xs font-medium uppercase tracking-[0.22em] text-[var(--pax-mute,#6b645c)]">
Meta balls
</p>
<h3 className="mt-3 max-w-md text-2xl font-semibold leading-snug tracking-tight [text-wrap:balance] mix-blend-difference">
Blobs chase the pointer and fuse where they touch.
</h3>
</div>
)}
</div>
</div>
);
}