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).