Metaballs
Flat, solid shapes that reach for each other, merge into one, and cleanly let go.
Two blobs sitting apart are just two blobs. Bring them close and a neck of the same colour reaches across the gap, pulls them into one shape, and lets go cleanly as they part. This page builds that effect up live, from five draggable blobs to a waterline, a chase, and tags you sort by dropping them on each other.
I started this because the trick kept turning up in interfaces I liked: menus that bud off buttons, loaders that pool and split, tab highlights that stretch between stops. All of them lean on the same old graphics idea, metaballs, and I had never built it myself.
It is simpler than it looks. Each shape gives off an influence that fades with distance, and every pixel sums that influence and paints itself only where the sum clears a threshold. Near the threshold the sum changes smoothly, so two shapes that come close push each other's edges over the line and fuse before they ever overlap.
The first build is the plainest version of that: five flat blobs of solid ink on paper, one WebGL shader doing the sums. Grab a blob, drag it into another, pull it back out. Goo sets how far each blob's influence reaches, the difference between blobs that lunge across the gap and blobs that barely acknowledge each other.
The field
Five solid blobs and a threshold. Drag one into another, then pull it back out.
Drag a blob, or focus the canvas and steer with the arrow keys.
Playing with the goo slider gave me the next idea: moving the threshold felt less like tuning a shape and more like water moving over land that was already there. So the second build draws the field honestly, as a chart. Every blob is a hill and the threshold is the waterline. Drain the water and the islands join into one coast; raise it and the coast breaks back into peaks. The hills still drag.
Sea level
The same field read as a chart. Every blob is a hill, the waterline is the threshold, and the hills still drag.
So far every merge had happened because I dragged something. To watch the field move on its own, I gave a chain of drops one job: catch your pointer. Move fast and the chain strings out into separate drops; stop, and they pile back into one pool. Slack sets how loosely they follow.
The chase
A chain of drops with one job: catch the pointer.
That pool pointed back at the interfaces this all started with. Here the blobs are tags, and the merge finally means something: drop one on another and they are one group, tear it away and it is loose again. No separate hit test decides this. The readout under the board runs the same sums the shader paints, so a group forms at the exact moment the ink fuses on screen. Group travel with food, pull work off on its own, and watch it keep up.
The sorter
Tags you drag into piles. Whatever reads as one shape is one group.
Next is more of that. The field is one shader with no animation loop, cheap enough to hide inside small components: a tab highlight that stretches between stops as it slides, a badge that buds off its icon and settles back. The sorter is the pattern for both. That is the next experiment.
Code
The real source that powers the demos, ready to copy.
"use client";import { type RefObject, useEffect, useRef } from "react";import { cn } from "@/lib/utils";import { type MetaballsParams, useMetaballs } from "./useMetaballs";export type { MetaballsParams };/** Imperative handle the demo uses to rearrange the field from outside. */export interface MetaballsApi { /** Throw the blobs to fresh, well-spread spots. */ scatter: () => void;}/** * Drop-in metaball field. Give it a params object and it paints flat, * draggable blobs to a canvas, redrawing whenever the params change or a blob * moves. Self-contained: it owns its own WebGL2 context, pointer and keyboard * wiring, and cleans everything up on unmount. Pass an apiRef to reach the * scatter action from outside. */export function Metaballs({ params, initialPositions, className, apiRef,}: { params: MetaballsParams; /** Authored starting spots in 0..1 canvas space, one per blob. */ initialPositions?: ReadonlyArray<readonly [number, number]>; className?: string; apiRef?: RefObject<MetaballsApi | null>;}) { const canvasRef = useRef<HTMLCanvasElement>(null); const { status, error, scatter } = useMetaballs( canvasRef, params, initialPositions, ); useEffect(() => { if (!apiRef) return; apiRef.current = { scatter }; return () => { apiRef.current = null; }; }, [apiRef, scatter]); return ( <div className={cn("relative w-full overflow-hidden", className)} style={{ backgroundColor: params.background }} > {/* The canvas itself is the interactive surface: pointer drags and the keyboard path (Enter or Space picks the next blob, arrow keys move it, Shift for fine steps) are wired up natively inside useMetaballs. */} <canvas ref={canvasRef} tabIndex={0} aria-label="Metaball field. Drag a blob with the pointer, or press Enter to pick the next blob and the arrow keys to move it. Hold Shift for fine steps." className={cn( "block h-full w-full touch-none transition-opacity duration-300", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#6B97FF]", status === "ready" ? "opacity-100" : "opacity-0", )} /> {status === "error" && ( <div className="absolute inset-0 flex items-center justify-center p-6"> <p className="text-center text-[13px] text-black/50">{error}</p> </div> )} </div> );}Credits
Where this came from.
- Sources
- Lucas Bebber, The Gooey Effect (CSS-Tricks)Lucas Bebber, Creative Gooey Effects (Codrops)Interactive droplet-like metaballs with Three.js and GLSL (Codrops)
- Date
- July 2026
- Tags
- WebGLMetaballsShaderInteraction
- Author
- Zhiyuan Guo
Zhiyuan Guo
zhiyuanguo/website