Workspace / component / animations

Meta Balls

Gooey blobs chase the pointer and merge on contact using an SVG filter threshold. Organic, tactile, and entirely dependency free.

Workspacecomponentanimations

paxfx add blocks/component-animations-meta-ballslocal only

Tier: 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>
  );
}