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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Metaballs } from "@/components/metaballs"; <Metaballs />Customise
Capped at 12 — GLSL ES 1.0 cannot loop to a uniform bound, so the limit is compiled in.
Where the field is cut. Lower makes everything fatter and merge sooner.
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.