Blog / Canvas & Visual Effects

Canvas Metaballs: Creating a Liquid Blood Effect (blur + contrast filter)

Blood that drips, merges and pools — in metaballs using pure Canvas and the CSS blur + contrast trick. Drop physics, cursor blob, zero asset, zero library. Copy-paste vanilla code.

Metaballs — those organic shapes that merge when they get close, like drops of mercury or lava — have long had a reputation for being expensive: the exact calculation evaluates a scalar field for every pixel. This article describes the Cursed Blood Goo effect, the free effect from Effect.Labs' October 2026 Halloween sprint: liquid blood that drips, merges and accumulates into a pool, with a blob that follows the cursor — all in pure Canvas with a single CSS filter line, no library, no asset, at 60fps.

Definition — Metaballs. Blobs whose surfaces weld together as soon as they overlap, giving a liquid/gelatinous appearance. The classic method computes, for each pixel, the sum of each blob's influence and thresholds the result. We will achieve the same look without that per-pixel cost.

The trick: blur + contrast

The technique (popularised by Lucas Bebber) relies on two chained filters applied to the layer containing the blobs:

  1. blur() spreads the edges of each blob into a soft gradient. Two nearby blobs have their fuzzy halos overlap.
  2. contrast() at a high value crushes those intermediate values: everything below the threshold snaps to the background, everything above to full color. Result: a reconstructed sharp edge, and wherever two halos overlapped, a single welded organic mass.
.goo-scene { background: #0b0406; }            /* OPAQUE dark background, mandatory */
.goo-canvas { filter: blur(7px) contrast(16); } /* the metaball combo */
Pitfall #1. contrast() acts on color channels, never on alpha. You must therefore draw solid blobs on an opaque dark background: the blur mixes blob color with background, then contrast re-thresholds. On a transparent canvas, no merging occurs — this is the most common mistake.

Drawing the drops

On the Canvas side, it is intentionally simple: fill the opaque background, then draw each drop as a plain filled circle of blood color. All the "liquid" comes from the CSS filter applied on top.

const ctx = canvas.getContext('2d');
const COLOR = '#c81e2d';

function render() {
  ctx.fillStyle = '#0b0406';           // opaque background (re-thresholded to black by contrast)
  ctx.fillRect(0, 0, W, H);
  ctx.fillStyle = COLOR;
  for (const d of drops) {             // each drop = a filled disk
    ctx.beginPath();
    ctx.arc(d.x, d.y, d.r, 0, 6.2832);
    ctx.fill();
  }
}

The physics: fall, pool, cursor

Three behaviors bring the blood to life, all very cheap:

1. The drip

A new drop spawns at the top at a regular interval, subject to gravity. A few dozen are enough — the visual merging does the rest.

if (frame % 11 === 0 && drops.length < 130)
  drops.push({ x: Math.random()*W, y: -12, vx: 0, vy: 1.2,
               r: 9 + Math.random()*9 });

for (const d of drops) { d.vy += 0.28; d.x += d.vx; d.y += d.vy; }

2. The rising pool

When a drop reaches the floor, it does not disappear: it feeds the pool level (proportional to its area). The pool is drawn as a sinusoidally rippled surface — and it slowly drains to reach an equilibrium rather than overflow.

if (d.y + d.r*0.4 >= H - pool) { pool += d.r*d.r / W * 0.8; remove(d); }
pool = Math.max(0, pool - 0.14);     // slow drainage → equilibrium

3. The cursor blob

The cursor carries a large blob (which merges with everything it touches, thanks to the filter). Moving fast shoots droplets in the direction of movement, and it attracts nearby drops. This is what makes the effect feel tactile.

// blob following the cursor (smoothed)
bx += (mx - bx) * 0.3; by += (my - by) * 0.3;
// cursor speed → droplet projection
const speed = Math.hypot(mx - pmx, my - pmy);
if (speed > 13) drops.push({ x: bx, y: by,
  vx: (mx - pmx)*0.07, vy: (my - pmy)*0.07 + 1, r: 6 + Math.random()*6 });

The full code (handling of devicePixelRatio, drop attraction toward the cursor, customizable background) is available directly on the Visual Effects catalog pagethis effect is free.

Customize: blood, slime, ink, lava

A single color variable transforms the effect. Two or three tweaks are enough to change the mood:

ParameterRangeEffect
Color#c81e2d / #5ad12e / #111 / #ff7a18Blood / toxic slime / ink / lava
contrast()10 → 22Higher = sharper edges, more "gelatinous"
blur()5 → 10 pxThickness of the merge between drops
rate frame % N6 → 20Drip speed

Remember to keep the opaque background consistent with the hue (a very dark green for slime, for example), otherwise the contrast re-thresholding turns grey.

Performance & accessibility

A single canvas, ~100 drops, one GPU filter: the effect holds 60fps on recent mobile devices. A few safeguards:

  • Cap the number of drops (130 here) and devicePixelRatio to 2.
  • The blur()+contrast() filter is executed by the GPU compositor — much faster than a scalar field computed in JS.
  • prefers-reduced-motion: draw a frozen scene (pool + a few resting drops), no animation loop. The effect is purely decorative; content remains accessible.

What ChatGPT and v0 miss

Ask a generator for "canvas metaballs" and you almost always get a per-pixel scalar field (double loop over width × height), which lags as soon as you go beyond a few blobs. What AI misses:

  • The blur + contrast trick: the "free" GPU solution is rarely suggested spontaneously.
  • The mandatory opaque background: AI-generated code draws on a transparent canvas → no merging, and nobody understands why.
  • The physics that brings it to life: rising pool, drainage to equilibrium, droplet projection at cursor speed.
  • devicePixelRatio: forgotten → blurry render on Retina (ironic for an already-blurred effect).
  • prefers-reduced-motion: an accessibility rule regularly absent from generated results.

FAQ

Does the blur + contrast filter also work in DOM/SVG?

Yes — that is even its original use case (on div elements or an SVG filter combining feGaussianBlur + feColorMatrix). With Canvas you keep a single element to composite, which remains the simplest and fastest approach.

Can I use it with React, Vue or Svelte?

Yes: vanilla JS code, no npm dependency. In React, wrap the loop in a useEffect with cleanup (cancelAnimationFrame); in Svelte, use onMount/onDestroy.

The full, ready-to-use code

This effect is free this month. Customize the color live and copy-paste the code. Access 800+ more premium effects with the subscription.

Join the founders — €9.90 / month