GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Metaball Effect Scenes, Marching Squares Canvas

No library, no WebGL — marching squares links grid cells whose field crosses one threshold, and blobs merge and split like liquid, the whole trick behind every

Auto-plays · click a tile to jump to its section · all nine in one zip

Why one field layer bleeds into nine scenes

The nine scenes climb one axis: how much of the marching-squares pipeline each one actually runs. 01 sums a lookup-table falloff per pixel and fills whole grid cells above threshold — no boundary tracing yet, just the stair-stepped mask that motivates the next item. 02 keeps that identical field but adds the 16-case table itself, turning the same crossing into a smooth curved outline through linear interpolation. 03 reuses 02's boundary math untouched and only changes what happens to the loop it returns — tinting it by the nearest ball and layering a fading trail, closer to a recursive stem redrawn from scratch every frame than to a blurred filter. 04 drops thresholding altogether and reads the raw field value as lightness through lighter compositing, so proximity itself becomes glow. 05 and 06 both keep the full contour pipeline but drive the balls differently — 05 by a buoyancy-temperature cycle, 06 by pointer acceleration and damping. 07 tracks five thresholds at once instead of one, painting bands the way two chemicals diffusing at different rates settle into stripes rather than a single blob edge. 08 swaps the field's sources from balls to a font's own alpha pixels, and 09 layers a transient shockwave onto the same field sum that a grid passing velocity from cell to neighboring cell uses for its own ripples. Follow them in order and item 07's contour bands are really item 02's single threshold cut five times instead of once.

Why metaballs need marching squares — the math of scalar fields and threshold edges

A field is just addition, and addition is what makes a bridge grow

Every item on this page traces back to the same scalar field: sum each ball's distance falloff (radius² / (radius² + distance²)-shaped, item 01's lookup table and item 05's direct division are the same curve two ways) at a grid point, and the region where that sum exceeds a threshold — THRESH between 0.92 and 1.05 across the set — is the metaball body. Because the field adds, two balls drawing close push their overlapping sums past threshold in the strip between them before either circle's own edge gets there, which is exactly the bridge that a metaballs animation is known for and that drawing two separate circles can never produce.

Marching squares is a lookup table, not a fill algorithm

The marching squares algorithm that item 02 introduces reads each grid cell's four corners as a 4-bit case — TL=1, TR=2, BR=4, BL=8 — and looks the case up in a 16-entry table that already knows which pair of edges the boundary crosses. Two of those sixteen cases (5 and 10) are ambiguous, since a diagonal pair of corners can mean either two separate blobs pinched at the center or one bridge connecting them; sampling the cell's own center against the threshold breaks the tie, the same disambiguation any 2D marching-squares implementation needs.

Linear interpolation is what turns a stair-step into a curve

Once a cell's case names which edges the boundary crosses, ip = (THRESH - a) / (b - a) finds exactly where on that edge the two corner values straddle the threshold, and joining those points with quadraticCurveTo (rather than straight lines) is what makes items 02 through 09 read as liquid instead of low-poly. Skip that interpolation and fill whole cells solid instead, the way item 01 deliberately does, and the same field produces the blocky, pre-marching-squares look that motivates the rest of the set.

The same field sum works as a metaball 2d scene under three different readings

A field crossing a threshold is a boundary (items 02, 03, 05, 06, 08), but the identical numbers also work as a lightness map (item 04's lighter composite) or as five stacked boundaries at once (item 07's bands) — a metaball 2d scene never needs a different simulation per look, only a different way of reading the same sum. That is also why none of the nine reaches for WebGL: every read happens on plain numbers already sitting in a JavaScript array.

Code per item

Every function under a heading below is pulled straight from that item's own script tag, not paraphrased into pseudocode, so pasting it into a project reproduces the demo above it constant for constant.

01One field layer — counting threshold-crossing pixels as a first metaball

Sum a scalar field of inverse-distance falloff from each ball center per pixel and fill only the grid cells over the threshold — a bridge seeps between two balls though no circle was ever drawn. It suits a single liquid mass sitting behind a hero headline.

One field layer — counting threshold-crossing pixels as a first metaball
Open in a new tab

01 · One field layer — counting threshold-crossing pixels as a first metaball — Measured area 33.374% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataputImageDatarequestAnimationFrame

A baked lookup sprite turns per-pixel falloff into one array read

function fieldAt(px, py, t) {
  var sum = 0, closest = -1, closestD = 1e9;
  for (var i = 0; i < balls.length; i++) {
    var p = ballPos(balls[i], t);
    var dx = px - p[0], dy = py - p[1];
    var dist = Math.sqrt(dx * dx + dy * dy);
    // falloff = a value read from the lookup table (a sprite baked once via putImageData), scaled by the ball's radius
    // at the sprite radius (64) falloff is exactly 1, so scaled = dist * 64 / rad is exactly the boundary at that distance
    var scaled = dist * (SPR_R / balls[i].rad);
    var v = scaled >= SPR_R ? 0 : lut[Math.min(SPR_R - 1, Math.round(scaled))];
    sum += v;
    if (dist < closestD) { closestD = dist; closest = i; }
  }
  return [sum, closest];
}

02Marching squares — the 16-case table that links the boundary into an outline

Encode the four corner crossings of each cell as a 4-bit case, interpolate the edge points linearly and join them with curves — the stair-stepped pixel edge from item 01 becomes a smooth closed outline. It reads well as a liquid silhouette behind a logo or mascot mark.

Marching squares — the 16-case table that links the boundary into an outline
Open in a new tab

02 · Marching squares — the 16-case table that links the boundary into an outline — Measured area 34.129% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Path2DquadraticCurveTo

Sixteen corner patterns collapse to five distinct edge pairs

function caseSegments(cs) {
  switch (cs) {
    case 0:  return [];
    case 15: return [];
    case 1:  return [[EDGES.T, EDGES.L]];
    case 14: return [[EDGES.T, EDGES.L]];
    case 2:  return [[EDGES.T, EDGES.R]];
    case 13: return [[EDGES.T, EDGES.R]];
    case 3:  return [[EDGES.L, EDGES.R]];
    case 12: return [[EDGES.L, EDGES.R]];
    case 4:  return [[EDGES.R, EDGES.B]];
    case 11: return [[EDGES.R, EDGES.B]];
    case 6:  return [[EDGES.T, EDGES.B]];
    case 9:  return [[EDGES.T, EDGES.B]];
    case 7:  return [[EDGES.L, EDGES.B]];
    case 8:  return [[EDGES.L, EDGES.B]];
    case 5:  return 'AMB-x'; // ambiguous, center low: the TL and BR blobs stay separate
    case 10: return 'AMB-+'; // ambiguous, center high: the two blobs connect
  }
  return [];
}

03Gooey merge — the real path seeps, with no SVG filter

Fill and stroke an actually computed path — no blur filter — and the balls merge gooily like dragged ink, with color trailing along the path as it fades. It suits a sticky merge on a button icon or a navigation hover.

Gooey merge — the real path seeps, with no SVG filter
Open in a new tab

03 · Gooey merge — the real path seeps, with no SVG filter — Measured area 43.647% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Path2DrequestAnimationFramesetAttribute

Each closed loop is tinted by whichever ball sits nearest its own centroid

function loopCentroid(loop) {
  var sx = 0, sy = 0;
  for (var i = 0; i < loop.length; i++) { sx += loop[i][0]; sy += loop[i][1]; }
  return [sx / loop.length, sy / loop.length];
}
function nearestBall(cx, cy, t) {
  var best = 0, bd = 1e9;
  for (var i = 0; i < balls.length; i++) {
    var p = ballPos(balls[i], t);
    var d = (p[0] - cx) * (p[0] - cx) + (p[1] - cy) * (p[1] - cy);
    if (d < bd) { bd = d; best = i; }
  }
  return best;
}

04Glow bloom — reading field strength as lightness

Map field strength to lightness everywhere, above and below threshold, and composite with lighter — the interior brightens as balls approach and flares the instant they merge. It fits a dark-mode hero or a music-service background.

Glow bloom — reading field strength as lightness
Open in a new tab

04 · Glow bloom — reading field strength as lightness — Measured area 36.467% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createRadialGradientglobalCompositeOperationrequestAnimationFrame

Proximity between balls drives the same flare that field strength drives per pixel

function falloff(u) {
  var q = u * GLOW;
  return 1 / (1 + q * q);
}

// proximity to nearby balls (0..1) — brightens the interior as they get closer
function heat(b) {
  var s = 0;
  for (var j = 0; j < balls.length; j++) {
    var o = balls[j];
    if (o === b) continue;
    var d = Math.hypot(o.x - b.x, o.y - b.y);
    var lim = (o.r + b.r) * 1.5;
    if (d < lim) s += 1 - d / lim;
  }
  return Math.min(1, s);
}

function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function mix(c1, c2, k) {
  return [c1[0] + (c2[0] - c1[0]) * k, c1[1] + (c2[1] - c1[1]) * k, c1[2] + (c2[2] - c1[2]) * k];
}

05Lava lamp — blobs rising on buoyancy and heat

Give each ball a heat cycle that lifts it when warm near the base and sinks it when cool near the top, and marching squares catches the drift as a live outline every frame. It works as mood lighting or a loading-screen filler.

Lava lamp — blobs rising on buoyancy and heat
Open in a new tab

05 · Lava lamp — blobs rising on buoyancy and heat — Measured area 53.512% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramequadraticCurveToPath2D

The same field sum from item 01 feeds a grid sampled once per frame

function fieldAt(px, py) {
  var s = 0;
  for (var i = 0; i < balls.length; i++) {
    var b = balls[i];
    var dx = px - b.x, dy = py - b.y;
    var q = (dx * dx + dy * dy) / (b.r * b.r);
    s += 1 / (1 + q);
  }
  return s;
}

function sampleGrid() {
  for (var j = 0; j < rows; j++) {
    for (var i = 0; i < cols; i++) {
      fieldGrid[j * cols + i] = fieldAt(i * GRID, j * GRID);
    }
  }
}

06Pointer attraction — a pulled bridge that tears

Accelerate only the balls near the pointer toward it and release the rest with damping — a thick bridge forms on approach and stretches until it snaps the moment the cursor leaves, all from arithmetic. It suits a touch-responsive intro or a playful landing page.

Pointer attraction — a pulled bridge that tears
Open in a new tab

06 · Pointer attraction — a pulled bridge that tears — Measured area 32.608% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

addEventListenerrequestAnimationFramequadraticCurveTo

Distance to the pointer decides acceleration, not a fixed tween path

function update(t, dt) {
  // auto mode — without pointermove, a circular patrol path stands in as a virtual pointer
  if (!hasPointer) {
    var a = t * 0.7;
    ptrX = W * 0.5 + Math.cos(a) * W * 0.34;
    ptrY = H * 0.5 + Math.sin(a * 1.15) * H * 0.32;
  }
  var m = 16;
  for (var i = 0; i < balls.length; i++) {
    var b = balls[i];
    var dx = ptrX - b.x, dy = ptrY - b.y;
    var d = Math.hypot(dx, dy) || 1;
    if (d < PULL_R) {
      var pull = (1 - d / PULL_R) * PULL_K;
      b.vx += (dx / d) * pull * 60 * dt;
      b.vy += (dy / d) * pull * 60 * dt;
    } else {
      // damping — eases back toward the home position
      b.vx += (b.homeX - b.x) * 0.9 * dt;
      b.vy += (b.homeY - b.y) * 0.9 * dt;
    }
    // a little wander — avoids a fully static rest state
    b.vx += Math.cos(t * 0.8 + b.ph) * 3 * dt;
    b.vy += Math.sin(t * 0.9 + b.ph) * 3 * dt;
    b.vx *= 0.9; b.vy *= 0.9;

07Contour bands — stacking thresholds into a topographic map

Trace several thresholds at once and tint each band a different pastel — the same field turns into a topographic scene like a weather or pressure map. It suits data art or a section background behind a stat block.

Contour bands — stacking thresholds into a topographic map
Open in a new tab

07 · Contour bands — stacking thresholds into a topographic map — Measured area 52.869% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDatacreateLinearGradientrequestAnimationFrame

Five stacked thresholds classify every low-res pixel into a band

function terrain(nx, ny, t) {
  var a = Math.sin(nx * 5.4 + t * 0.25) * Math.cos(ny * 4.1 - t * 0.19);
  var b = Math.sin((nx + ny) * 3.1 - t * 0.31);
  return (0.5 + 0.32 * a + 0.18 * b);
}

function classify(t) {
  var img = fieldCtx.getImageData(0, 0, LOWW, LOWH);
  var d = img.data;
  var thr = [];
  for (var k = 1; k <= BANDS; k++) thr.push(k / (BANDS + 1));
  for (var y = 0; y < LOWH; y++) {
    for (var x = 0; x < LOWW; x++) {
      var idx = (y * LOWW + x) * 4;
      var v = d[idx] / 255;                 // the heat-source field (0..1)
      var mix = v * 0.72 + terrain(x / LOWW, y / LOWH, t) * 0.28;
      var band = 0;
      for (var k2 = 0; k2 < thr.length; k2++) if (mix >= thr[k2]) band = k2 + 1;
      bandCtx.fillStyle = band === 0 ? C_INK : BAND_COL[band - 1];
      bandCtx.fillRect(x, y, 1, 1);
    }
  }
}

08Liquid type — text pixels born as field sources

Draw the text, sample its alpha pixels as field sources through getImageData, and the whole word becomes a metaball mass that drifts and reshapes like a typographic intro. It suits a hero headline or a letter-morph transition.

Liquid type — text pixels born as field sources
Open in a new tab

08 · Liquid type — text pixels born as field sources — Measured area 30.161% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

measureTextgetImageDataquadraticCurveTo

Only pixels the glyph actually painted become ball centers

    txtCtx.fillText(TEXT, cx, cy);

    var img = txtCtx.getImageData(0, 0, W, H);
    var d = img.data;
    seeds = [];
    for (var y = 4; y < H - 4; y += STRIDE) {
      for (var x = 4; x < W - 4; x += STRIDE) {
        var a = d[(y * W + x) * 4 + 3];
        if (a > 120) {
          seeds.push({ x: x, y: y, ph: rand() * Math.PI * 2, sp: 0.8 + rand() * 1.6 });
        }
      }
    }
  }

09Rain pond — drop shockwaves that push the blobs apart

Add a circular shockwave to the field the instant a drop lands, then remove it — the pond mass shudders and splits, merging again on the ripples that follow. It fits a weather scene or a mood-page hero.

Rain pond — drop shockwaves that push the blobs apart
Open in a new tab

09 · Rain pond — drop shockwaves that push the blobs apart — Measured area 52.152% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDataputImageDatarequestAnimationFrame

A landed drop becomes a shockwave, not a new ball

function spawnDrop(t) {
  drops.push({ x: (0.12 + rand() * 0.76) * LOWW, y: -6, vy: 60 + rand() * 40 });
}

// ── simulation ──
function update(t, dt) {
  if (t >= nextDropAt) {
    spawnDrop(t);
    nextDropAt = t + (RAIN_MS / 1000) * (0.55 + rand() * 0.9);
  }
  // raindrop falls — a shockwave fires the instant it touches the surface (the pond's average top level)
  var surf = LOWH * 0.62;
  for (var i = drops.length - 1; i >= 0; i--) {
    var dp = drops[i];
    dp.y += dp.vy * dt;
    if (dp.y >= surf) {
      shocks.push({ x: dp.x, y: dp.y, born: t, life: 0.55, amp: SHOCK });
      drops.splice(i, 1);
    }
  }
  while (shocks.length && t - shocks[0].born > 1.2) shocks.shift();

Where a metaball demo actually earns its place

When a button or a logo needs a highlight that chases the cursor, 06's pointer attraction is the fit — a bridge that pulls in and tears back apart, so the hit area can stay exactly where it always was while the visual reach grows. A brand intro where letters pool together like liquid and pull apart again is 08's liquid type, ported straight over.

For background decoration, 09's rain pond works at a low intensity as ambient motion, and a loading indicator rarely needs more than 01's single field layer — the simplest shape the set has.

Where the boundary breaks once the threshold shifts a little

Filling separate circles never grows a bridge

Draw each ball as its own filled circle and composite them with alpha, and two circles can only ever overlap — the field never adds, so no strip between two close balls crosses a shared threshold on its own. The merge in every item here comes from summing falloff values first and thresholding the sum second; reverse that order and the whole set collapses into ordinary compositing.

Leaving lighter compositing set after item 04's flare keeps brightening the scene

Item 04 switches to globalCompositeOperation = 'lighter' so overlapping glows add instead of overwrite, then has to switch back to source-over before drawing anything that must not stack — miss that reset and the mist layer or the next frame's background paint keeps compounding brightness until the canvas washes out to white within a few seconds.

A threshold below 0.92 turns every gap into one solid mass

Item 01's THRESH = 0.92 and item 02's THRESH = 1.0 sit close to the value where a single ball's own falloff crosses one on its own edge. Drop the threshold much further and neighboring balls that were never meant to touch start summing past it too, and instead of bridges forming and snapping as balls move, the nxvdqxgd scene reads as one blob that never separates — the merge/split behavior this whole set depends on needs balls to spend most of a cycle below threshold with each other.

A grid finer than about 4px collapses the frame budget

Cell count grows with the square of how fine CELL or GRID is set — items 02 and 03 use 8px, item 06 uses 22px — and halving that spacing roughly quadruples the corner samples sampleGrid and collectSegments have to touch every single frame. Push it under 4px chasing a smoother curve and the per-frame cost outruns what requestAnimationFrame can clear before the next repaint, well before the outline visibly improves.

Three field-radius variants and their reduced-motion fallback

One constant, three different blob scenes

Variant Changed value What it feels like
Two-tone pastel band field 07 contour bands BANDS 5 → 2 The topographic map collapses into two bold pastel fields instead of a graded stack
Low-res field at doubled grid spacing 02 marching squares CELL 8 → 16 The outline turns visibly faceted, trading curve smoothness for roughly a quarter of the per-frame corner samples
Seeded static poster frame any item's stillFrame() time argument fixed The whole set freezes on one reproducible frame instead of looping, useful for a thumbnail that has to match the live demo

Nothing here is drawn by hand, so nudging one constant reshapes the whole scene instead of asking someone to redraw a metaballs animation frame by frame.

What reduced-motion keeps

A single matchMedia('(prefers-reduced-motion: reduce)') check on load decides whether requestAnimationFrame ever runs at all — when it comes back true, every one of the nine items renders exactly one still frame (seven through a dedicated stillFrame() helper; 05 and 06 inline the same call instead), adds an is-still class to the canvas, and stops there instead of animating. A visitor who asked for less motion still sees a complete blob scene, just a frozen one, never a blank canvas.

Pointer input never blocks a keyboard visitor

Item 06's pointer attraction reads coordinates from pointermove on a tabindex="0" field element, so a keyboard user landing on the demo isn't stuck behind a mouse-only interaction — without a pointer, hasPointer stays false and the circular auto-patrol path keeps the attraction moving on its own. The other eight items read no pointer or keyboard input at all, since their motion comes entirely from seeded time rather than an input device.

Questions people ask before wiring up a metaball

What is the difference between a metaball effect and a simple blur-merge trick

A blur-merge trick (an SVG feGaussianBlur plus feColorMatrix, or a CSS filter) approximates the look by blurring two shapes together and thresholding the blur — it softens at any zoom and can't drive color per path segment. A metaball effect as built here sums an actual scalar field and traces its threshold boundary with marching squares, so the outline stays crisp at any zoom and, as item 03 shows, can carry per-loop color along the path itself.

Can I paste this marching squares 2d code in and use it as-is

Yes — every code block above is copied from the running demo's own source, not retyped, and each item's full file (HTML, SCSS, and JS) sits inside the zip behind the password on this page. Nothing in this set of nine reaches for an external library, WebGL, or a CDN, so its canvas markup and script tag can be lifted into any page and it behaves the same way it does here.

Why do items with fewer, bigger blobs measure a smaller painted area than items with many small ones

All nine items sample 24 frames and measure 23 of them as changed from the previous frame, but the painted-area percentage varies by scene, not by that frame count. Item 08's liquid type covers about 30% of the canvas because its field sources are thin glyph strokes, while item 05's lava lamp covers over 53% because nine large, slow-drifting blobs fill most of the frame at once — the same marching-squares boundary, measured over a different amount of field.

Sources: MDN — CanvasRenderingContext2D · MDN — CanvasRenderingContext2D.globalCompositeOperation · MDN — Window.requestAnimationFrame() · MDN — ImageData · MDN — prefers-reduced-motion · Wikipedia — Marching squares, the 2D case of the marching cubes algorithm this whole set traces by hand.

Enter the archive password

The password is inside this article. You will find it as you read.