MynduiMyndui
0

Anatomy of Liquid Metaballs

How SVG circles, a classic goo filter, toroidal drift, and a cursor blob merge without React re-renders per frame.

01/04Circles, not meshes
Blobs
blobCount=7 · radius ~8–16% of min side
Paint
setAttribute cx/cy — no React re-render
Wrap
toroidal edges
tsx
b.r = min * (0.08 + Math.random() * 0.08);b.vx = (Math.random() - 0.5) * 0.6 * speed;b.vy = (Math.random() - 0.5) * 0.6 * speed;
01

Circles, not meshes

LiquidMetaballs is physics on SVG circles under a blur + contrast filter. Positions update with setAttribute so React stays out of the hot path — the rAF loop never calls setState.

Defaults: blobCount={7}, colors cycling #6366f1 / #a855f7 / #ec4899 / #06b6d4, radii roughly 8–16% of the short side, velocity (rand - 0.5) * 0.6 * speed.

Edges wrap toroidally — leave left, enter right — so the field never piles up in a corner.

tsx
 
const paint = () => {
  for (const b of blobsRef.current) {
    b.el?.setAttribute("cx", `${b.x}`);
    b.el?.setAttribute("cy", `${b.y}`);
  }
};

Writing attributes skips reconciliation. The circles are created once; the loop only mutates geometry.

02/04The goo chain
raw
gooeyness=16
Blur
feGaussianBlur stdDeviation={gooeyness}
Punch
alpha ×20 − 9
tsx
<feGaussianBlur stdDeviation={gooeyness} />  {/* default 16 */}<feColorMatrix values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" />
02

The goo chain

Classic metaball filter — blur, then punch alpha back up with a contrast matrix:

Overlapping discs fuse into one liquid silhouette. Higher gooeyness = softer merge.

03/04Cursor merge
Active
r = min(w,h)×0.1 while pointer in
Idle
r = 0 — no leftover disc
tsx
cursorRef.current?.setAttribute("r", active ? `${min * 0.1}` : "0");cursorRef.current?.setAttribute("cx", `${cursor.x}`);cursorRef.current?.setAttribute("cy", `${cursor.y}`);
03

Cursor merge

With interactive (default), an extra circle follows the pointer. While active its radius is min(w,h) * 0.1; on leave it collapses to 0 so no orphan disc remains:

It shares the same goo filter, so it melts into neighbors on contact.

04/04Result
04

The result

Move to merge. Leave and the cursor blob vanishes.

Physics on attributes, fusion in a filter, zero React re-renders per frame.

Lifecycle + a11y

ResizeObserver, IntersectionObserver, and visibilitychange gate the loop. prefers-reduced-motion stops motion (no still-frame bake — the last positions simply freeze). Root is aria-hidden; the SVG is role="presentation".

Motion Score

Liquid MetaballsCCPaint-triggering
CSVG + gooCircle positions through goo filter each frame
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →