Skip to content
2D

Metaballs

A summed inverse-square field, thresholded to a hard boundary. Because the field is additive, two blobs approaching each other grow a bridge before they touch — that bridging is what makes them read as liquid rather than as overlapping circles, and it falls out of the maths rather than being animated. The threshold uses a narrow smoothstep scaled by resolution, so the edge stays one pixel wide on any display: a hard cut aliases on a curved boundary, a wide one turns the mercury back into fog.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Metaballs } from "@/components/metaballs"; <Metaballs />

Customise

7

Capped at 12 — GLSL ES 1.0 cannot loop to a uniform bound, so the limit is compiled in.

0.15
1

Where the field is cut. Lower makes everything fatter and merge sooner.

1

The component

components/craft/2d/Metaballs.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Blobs that merge and separate like mercury, with a hard edge. * * A metaball field sums an inverse-square falloff from every centre, then * thresholds the result. Because the field is additive, two blobs that * approach each other produce a bridge before they touch — that bridging is * what makes them read as liquid rather than as overlapping circles, and it * falls out of the maths rather than being animated. * * The threshold is applied with a narrow smoothstep rather than a hard step: * a hard cut aliases badly on a curved boundary, and a wide one turns the * mercury back into fog. The width is scaled by resolution so the edge stays * one pixel wide on any display. */ const VERTEX = `attribute vec2 position;void main() { gl_Position = vec4(position, 0.0, 1.0); }`; /* MAX_BALLS is a compile-time constant because GLSL ES 1.0 cannot loop to a   uniform bound. The count uniform then masks off the unused ones. */const FRAGMENT = `precision mediump float;#define MAX_BALLS 12 uniform vec2  uResolution;uniform vec3  uBalls[MAX_BALLS];   /* xy = centre, z = radius */uniform int   uCount;uniform vec3  uColorA;uniform vec3  uColorB;uniform float uThreshold; void main() {  vec2 uv = gl_FragCoord.xy / uResolution;  float aspect = uResolution.x / uResolution.y;  vec2 p = vec2(uv.x * aspect, uv.y);   float field = 0.0;  for (int i = 0; i < MAX_BALLS; i++) {    if (i >= uCount) break;    vec2 centre = vec2(uBalls[i].x * aspect, uBalls[i].y);    float r = uBalls[i].z;    vec2 d = p - centre;    /* Inverse square, the classic metaball kernel: it never reaches zero, so       every blob contributes everywhere and the field stays smooth. */    field += (r * r) / max(dot(d, d), 1e-5);  }   /* Edge width tied to resolution, so the boundary is one pixel wide whatever     the display density — a fixed width shimmers on retina and aliases on 1x. */  float w = 2.5 / uResolution.y * 60.0;  float mask = smoothstep(uThreshold - w, uThreshold + w, field);   /* Colour ramps with field strength, so the thick middle of a blob reads     differently from the thin bridge between two. */  float depth = clamp((field - uThreshold) * 0.35, 0.0, 1.0);  vec3 colour = mix(uColorA, uColorB, depth);   gl_FragColor = vec4(colour * mask, 1.0);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) {  const s = gl.createShader(type);  if (!s) return null;  gl.shaderSource(s, src);  gl.compileShader(s);  return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] {  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!m) return [1, 1, 1];  const n = parseInt(m[1], 16);  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} export interface MetaballsProps {  /** Number of blobs, capped at 12 by the shader's loop bound. */  count?: number;  colorA?: string;  colorB?: string;  speed?: number;  /** Blob size. Larger merges more readily. */  radius?: number;  /** Where the field is cut. Lower makes everything fatter and merge sooner. */  threshold?: number;  /** The pointer becomes an extra blob. */  interactive?: boolean;  still?: boolean;  className?: string;} export function Metaballs({  count = 7,  colorA = "#c3f53c",  colorB = "#f4ffd4",  speed = 1,  radius = 0.15,  threshold = 1,  interactive = true,  still = false,  className = "",}: MetaballsProps) {  const ref = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = ref.current;    if (!canvas) return;    const gl = canvas.getContext("webgl", { alpha: false, antialias: false });    if (!gl) return;     const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vs || !fs) return;    const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vs);    gl.attachShader(program, fs);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     const buffer = gl.createBuffer();    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);    const position = gl.getAttribLocation(program, "position");    gl.enableVertexAttribArray(position);    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);     const uniform = (n: string) => gl.getUniformLocation(program, n);    const uResolution = uniform("uResolution");    const uBalls = uniform("uBalls");    const uCount = uniform("uCount");    gl.uniform3fv(uniform("uColorA"), toRgb(colorA));    gl.uniform3fv(uniform("uColorB"), toRgb(colorB));    gl.uniform1f(uniform("uThreshold"), threshold);     const MAX = 12;    const total = Math.max(1, Math.min(MAX, Math.round(count)));    // Each blob gets its own Lissajous phase so they never fall into lockstep,    // which would make the whole field pulse as one object.    const phases = Array.from({ length: total }, (_, i) => ({      ax: 0.7 + (i % 3) * 0.31,      ay: 0.5 + (i % 4) * 0.27,      px: i * 1.7,      py: i * 2.3,      r: radius * (0.7 + ((i * 37) % 60) / 100),    }));     const data = new Float32Array(MAX * 3);    const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     let uploadedWidth = 0;    let uploadedHeight = 0;     const resize = () => {      const dpr = Math.min(devicePixelRatio || 1, 2);      const w = Math.floor(canvas.clientWidth * dpr);      const h = Math.floor(canvas.clientHeight * dpr);      if (!w || !h) return;      if (w === uploadedWidth && h === uploadedHeight) return;      uploadedWidth = w;      uploadedHeight = h;      canvas.width = w;      canvas.height = h;      gl.viewport(0, 0, w, h);      gl.uniform2f(uResolution, w, h);    };     let pointerX = -9;    let pointerY = -9;    const onMove = (e: PointerEvent) => {      const box = canvas.getBoundingClientRect();      pointerX = (e.clientX - box.left) / box.width;      pointerY = 1 - (e.clientY - box.top) / box.height;    };    const onLeave = () => {      pointerX = -9;      pointerY = -9;    };     const start = performance.now();     const draw = (now: number) => {      resize();      const t = ((now - start) / 1000) * speed;       let used = 0;      for (let i = 0; i < total; i += 1) {        const p = phases[i];        // Kept inside 0.15–0.85 so blobs never park half off-screen, which        // reads as a rendering error rather than as motion.        data[used * 3] = 0.5 + Math.sin(t * p.ax + p.px) * 0.35;        data[used * 3 + 1] = 0.5 + Math.cos(t * p.ay + p.py) * 0.35;        data[used * 3 + 2] = p.r;        used += 1;      }       if (interactive && pointerX > -1 && used < MAX) {        data[used * 3] = pointerX;        data[used * 3 + 1] = pointerY;        data[used * 3 + 2] = radius * 1.35;        used += 1;      }       gl.uniform3fv(uBalls, data);      gl.uniform1i(uCount, used);      gl.drawArrays(gl.TRIANGLES, 0, 3);    };     let frame = 0;    const loop = (now: number) => {      draw(now);      frame = requestAnimationFrame(loop);    };     draw(reduced ? start + 3200 : start);    if (!reduced) {      if (interactive) {        canvas.addEventListener("pointermove", onMove);        canvas.addEventListener("pointerleave", onLeave);      }      frame = requestAnimationFrame(loop);    }     const observer = new ResizeObserver(() => {      resize();      if (reduced) draw(start + 3200);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      canvas.removeEventListener("pointermove", onMove);      canvas.removeEventListener("pointerleave", onLeave);      gl.deleteProgram(program);      gl.deleteShader(vs);      gl.deleteShader(fs);      gl.deleteBuffer(buffer);    };  }, [count, colorA, colorB, speed, radius, threshold, interactive, still]);   return <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} />;} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.4KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Draws one frame with the blobs mid-drift and stops. No pointer listener is attached.

Accessibility
  • Canvas is aria-hidden; decorative.
  • Adds no focusable elements.
  • The hard edge gives genuinely high contrast, so check anything laid over it against BOTH the blob colour and the ground.
Watch out
  • Every pixel loops over every blob. Cost is blobs x pixels, so 12 blobs full-screen on a 4K display is real work.
  • Needs WebGL, and renders nothing without it.