Baktiyar Ablaikhan

Metaballs

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Move to pull the gooey blobs toward your pointer; press to scatter them.

Code

"use client";

import { useEffect, useId, useRef } from "react";
import { prefersReducedMotion, rng, Surface } from "../ui";

type Ball = { x: number; y: number; vx: number; vy: number; r: number; hx: number; hy: number; ph: number };

const SEED = (() => {
  const r = rng(4);
  return Array.from({ length: 9 }, (_, i) => ({
    hx: 0.3 + (i % 3) * 0.2 + (r() - 0.5) * 0.08,
    hy: 0.32 + Math.floor(i / 3) * 0.18 + (r() - 0.5) * 0.08,
    r: 0.07 + r() * 0.035,
    ph: r() * Math.PI * 2,
  }));
})();

export default function Metaballs() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const svg = useRef<SVGSVGElement>(null);
  const circles = useRef<(SVGCircleElement | null)[]>([]);
  const cursor = useRef<SVGCircleElement>(null);
  const pointer = useRef({ x: 0.5, y: 0.5, on: false, down: false });

  useEffect(() => {
    const el = svg.current;
    if (!el) return;
    const still = prefersReducedMotion();
    const balls: Ball[] = SEED.map((s) => ({ ...s, x: s.hx, y: s.hy, vx: 0, vy: 0 }));
    const cur = { x: 0.5, y: 0.5, r: 0 };
    let raf = 0;
    let t = 0;
    const loop = () => {
      t += 1 / 60;
      const p = pointer.current;
      const rect = el.getBoundingClientRect();
      const aspect = rect.width / Math.max(1, rect.height);
      cur.x += (p.x - cur.x) * 0.18;
      cur.y += (p.y - cur.y) * 0.18;
      cur.r += ((p.on ? (p.down ? 0.13 : 0.095) : 0) - cur.r) * 0.12;
      for (const b of balls) {
        const tx = b.hx + Math.cos(t * 0.7 + b.ph) * 0.05;
        const ty = b.hy + Math.sin(t * 0.9 + b.ph * 1.3) * 0.06;
        b.vx += (tx - b.x) * 0.02;
        b.vy += (ty - b.y) * 0.02;
        if (p.on) {
          // the pointer blob pulls nearby balls toward it, or pushes them away when pressed
          const dx = (cur.x - b.x) * aspect;
          const dy = cur.y - b.y;
          const d = Math.hypot(dx, dy) + 0.0001;
          if (d < 0.35) {
            const f = (p.down ? -0.004 : 0.0016) * (1 - d / 0.35);
            b.vx += (dx / d) * f;
            b.vy += (dy / d) * f;
          }
        }
        b.vx *= 0.9;
        b.vy *= 0.9;
        b.x += b.vx;
        b.y += b.vy;
      }
      const H = 100;
      const W = 100 * aspect;
      el.setAttribute("viewBox", `0 0 ${W} ${H}`);
      balls.forEach((b, i) => {
        const c = circles.current[i];
        if (!c) return;
        c.setAttribute("cx", String(b.x * W));
        c.setAttribute("cy", String(b.y * H));
        c.setAttribute("r", String(b.r * H));
      });
      cursor.current?.setAttribute("cx", String(cur.x * W));
      cursor.current?.setAttribute("cy", String(cur.y * H));
      cursor.current?.setAttribute("r", String(Math.max(0, cur.r * H)));
      if (!still) raf = requestAnimationFrame(loop);
    };
    loop();
    return () => cancelAnimationFrame(raf);
  }, []);

  const setFrom = (e: React.PointerEvent) => {
    const r = svg.current!.getBoundingClientRect();
    pointer.current.x = (e.clientX - r.left) / r.width;
    pointer.current.y = (e.clientY - r.top) / r.height;
  };

  return (
    <Surface>
      <svg
        ref={svg}
        className="absolute inset-0 h-full w-full touch-none"
        preserveAspectRatio="none"
        onPointerMove={(e) => {
          pointer.current.on = true;
          setFrom(e);
        }}
        onPointerDown={(e) => {
          pointer.current.on = true;
          pointer.current.down = true;
          setFrom(e);
        }}
        onPointerUp={() => (pointer.current.down = false)}
        onPointerLeave={() => {
          pointer.current.on = false;
          pointer.current.down = false;
        }}
      >
        <defs>
          <filter id={`bencho-goo-${uid}`}>
            <feGaussianBlur in="SourceGraphic" stdDeviation="2.4" result="b" />
            <feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10" />
          </filter>
        </defs>
        <g filter={`url(#bencho-goo-${uid})`} fill="#8f8e89">
          {SEED.map((_, i) => (
            <circle key={i} ref={(n) => void (circles.current[i] = n)} />
          ))}
          <circle ref={cursor} fill="#151515" />
        </g>
      </svg>
      <p className="pointer-events-none absolute bottom-[6%] font-mono text-[0.7em] text-[#8a8984]">move to pull · press to scatter</p>
    </Surface>
  );
}
Prompt

Build "Metaballs", a cursor animation. What it does: Move to pull the gooey blobs toward your pointer; press to scatter them. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).