LiquidFree

Metaballs

Six indigo balls bounce around the whole frame and merge before they even touch: the outline is computed pixel by pixel on a 2D canvas, wherever the sum of their fields goes above 1, and the core gets lighter where the fields add up — 5 KB of JavaScript, sharp on Retina screens, paused off screen, a still image when the user reduces motion.

CanvasAnimationOrganic

Updated

Hover or click the scene to interact

This effect is free — the Liquid category contains 31 effects total, including 6 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Saint-Aubin-les-Essarts ServicesEventsCouncil

January 1, 2027

Saint-Aubin and Les Essarts become one town

One town hall, one council, two villages. What changes for your paperwork →

① Announcing the new merged town — home page of the website of two villages that merge

WhenOn January 1, the town website banner announces "Saint-Aubin and Les Essarts become one town"; behind the heading, three golden balls move closer, stretch toward each other and merge, then drift apart again.
WhyThe bridge of matter that forms before contact shows a gradual coming together, not a collision: exactly the story of two towns merging. The outline stays sharp, so the banner keeps an official look.
Settingsdata-balls="3" and data-color="214,164,44" (the gold shared by both coats of arms) on the root; block stretched to the whole banner with position: absolute; inset: 0; dark bottom-to-top gradient under the heading, aria-hidden="true" on the root.

Our fermentation

48 hours of patience

Our kefir grains turn the sugar from the fruit into fine bubbles. No added gas: the yeasts do the work, at room temperature.

22 °Cin the cellar 48 hof fermentation

× 400 magnification — yeasts at work

② Budding yeasts under the microscope — "Our fermentation" page of a fruit kefir maker

WhenIn the middle of the page, a round eyepiece-style inset shows pale cells sticking together, splitting apart, a small one breaking off a big one; caption "× 400 magnification — yeasts at work".
WhyThe core that gets lighter where two cells overlap and the edge that stays sharp look like a microscope image; a CSS blur would give smudgy blobs with no outline.
Settingsdata-balls="5", data-color="222,212,178" (cream); root at 200% of the eyepiece scaled down by half with transform: scale(.5), for cells half the size and half the speed; round 245 px eyepiece, thick rim and dark radial-gradient vignette.
Tinting station 2 Mixing

Sage green 0417

Velvet matte base · 2.5 L · 4 colorants

Mixing:2 min 30

③ Tinting in progress — screen of the colorant dispenser in a DIY store's paint aisle

WhenThe clerk has started the "Sage green 0417" tint: the machine screen shows the color name and "Mixing: 2 min 30", over a background where sage green masses clump together and split apart across the whole frame.
WhyThe balls' color is a plain R,G,B value: the machine sets it to the ordered tint, and the masses blending over the whole surface picture the colorant spreading through the can.
Settingsdata-color="132,162,128", six balls (default setting); full-frame block, top and bottom bars in 86% black laid over it; static color swatch and progress bar.

How it works

The .fx-0391-root block is 250 px high and takes the full available width, background rgb(26, 26, 36). It holds a single <canvas class="metaballs-canvas"> set to display: block, at 100% of its container: the frame sets the size, the canvas fits into it. The script creates six balls with a 30 to 70 px radius, placed at random inside the frame, each with a speed of -1 to 1 px per frame on each axis. On every frame they move and bounce off the four edges; a ball never leaves the frame, even when it shrinks.

The drawing is a field computation, not a shape. For each pixel, the script adds up each ball's contribution, r² / (d² + 1) (radius squared over distance squared): it equals 1 on the edge of a lone ball and decreases further away. Where the sum goes above 1, the pixel takes the balls' color (99, 102, 241), lightened by up to +50 on each channel where the sum nears 2; elsewhere, the background color. When two balls get close, their fields add up between them: the sum crosses 1 before contact and a bridge of matter forms, then both outlines melt into a single mass. It is an iso-surface (the line where the field is exactly 1), the principle of "metaballs" in 3D software, with no CSS filter and no blur.

Since the computation runs pixel by pixel, its size is kept in check. On a Retina screen the canvas holds up to 2 pixels per CSS pixel (1,280 × 500 for a 640 × 250 block, sharp), within a limit of 650,000 pixels computed per frame: beyond that, the density goes back toward 1 to keep things smooth. Measured per frame: 1.5 ms in Chromium, 3 ms in Firefox and 1 ms in WebKit at 640 × 250; about 6, 9 and 4 ms on Retina or for a 1,200 × 500 block. The script animates every .fx-0391-root on the page separately; an IntersectionObserver pauses the loop as soon as the block leaves the screen (0 frames computed, measured) and it also stops when the tab is hidden. A ResizeObserver recomputes the canvas when the block changes size: 20 resizes in a row, 0 px of drift. Two settings go as attributes on the root: data-balls (number of balls) and data-color (color as R,G,B).

Accessibility

  • Reduced motion: under prefers-reduced-motion: reduce, the loop does not start. The canvas shows a single frame, the balls at their starting place; measured in Chromium, Firefox and WebKit, 0% of pixels change in 500 ms. The shapes stay visible, nothing moves.
  • Decorative content: the canvas carries no information. In a banner or behind a heading, add aria-hidden="true" to the root, as in the examples; if the balls illustrate something (cells, a mix), give the canvas role="img" and an aria-label that says what is shown.
  • Contrast: the indigo balls stand out clearly on the rgb(26, 26, 36) background. Text placed on top sits sometimes on the dark background, sometimes on a light ball: keep it on a dark overlay or a drop shadow, like the gradient in the first example.
  • No interaction: the effect reacts to neither mouse, finger nor keyboard. There is nothing to reach or miss, and it does not get in the way of keyboard navigation on the page.

Browser compatibility

Canvas 2D (createImageData, putImageData), ResizeObserver, IntersectionObserver, matchMedia: 5 KB of JavaScript, no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26: same motion, canvas at 1,280 × 500 on a Retina screen, paused off screen, still image under reduced motion, 0 errors. Speeds are expressed per displayed frame: on a 120 Hz screen the balls move twice as fast as at 60 Hz.

Chrome 64+✓ Full
Firefox 69+✓ Full
Safari 13.1+✓ Full
Edge 79+✓ Full
Mobile iOS 13.4+✓ Full
Android Chrome 64+✓ Full

Without ResizeObserver, the script follows the window resize event; without IntersectionObserver, the loop keeps running off screen. Without JavaScript, the block stays a dark 250 px rectangle, with no balls.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0391-root">
  <div class="metaballs-container">
    <canvas class="metaballs-canvas"></canvas>
  </div>
</div>
CSS
.fx-0391-root,
.fx-0391-root *,
.fx-0391-root *::before,
.fx-0391-root *::after {
  box-sizing: border-box
}

.fx-0391-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.metaballs-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.metaballs-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
JavaScript (fx-0391)
(function() {
  // ============================================
  // METABALLS - 2D canvas, field computed pixel by pixel
  // Per-block settings (optional) on .fx-0391-root:
  //   data-balls="3"            number of balls (6 by default)
  //   data-color="99,102,241"   ball color as R,G,B (indigo by default)
  // ============================================
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

  document.querySelectorAll('.fx-0391-root').forEach(function(root) {
    var canvas = root.querySelector('.metaballs-canvas');
    if (!canvas) return;
    var ctx = canvas.getContext('2d');
    var container = canvas.parentElement;

    var numBalls = parseInt(root.getAttribute('data-balls'), 10) || 6;
    var color = (root.getAttribute('data-color') || '99,102,241').split(',').map(Number);
    var background = [26, 26, 36];
    var PIXEL_BUDGET = 650000;

    var w = 0, h = 0, scale = 1, imageData = null;
    var balls = [];
    var raf = 0, visible = true;

    function resize() {
      w = container.clientWidth;
      h = container.clientHeight;
      // Retina screen: up to 2 canvas pixels per CSS pixel, within a limit of PIXEL_BUDGET pixels
      // computed per frame (the field is computed pixel by pixel: on a large frame, smoothness comes first)
      scale = Math.max(1, Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / Math.max(1, w * h))));
      canvas.width = Math.max(1, Math.round(w * scale));
      canvas.height = Math.max(1, Math.round(h * scale));
      imageData = ctx.createImageData(canvas.width, canvas.height);
      if (!balls.length) {
        for (var i = 0; i < numBalls; i++) {
          var radius = 30 + Math.random() * 40;
          balls.push({
            x: radius + Math.random() * Math.max(0, w - 2 * radius),
            y: radius + Math.random() * Math.max(0, h - 2 * radius),
            vx: (Math.random() - 0.5) * 2,
            vy: (Math.random() - 0.5) * 2,
            radius: radius
          });
        }
      }
      keepInside();
      drawMetaballs();
      start();
    }

    // A ball never leaves the frame, even after the frame shrinks
    function keepInside() {
      for (var i = 0; i < balls.length; i++) {
        var ball = balls[i];
        ball.x = w > 2 * ball.radius ? Math.min(Math.max(ball.x, ball.radius), w - ball.radius) : w / 2;
        ball.y = h > 2 * ball.radius ? Math.min(Math.max(ball.y, ball.radius), h - ball.radius) : h / 2;
      }
    }

    function drawMetaballs() {
      var W = canvas.width, H = canvas.height;
      var data = imageData.data;
      var n = balls.length;
      var bx = [], by = [], r2 = [], dy2 = [];
      for (var i = 0; i < n; i++) {
        bx[i] = balls[i].x * scale;
        by[i] = balls[i].y * scale;
        r2[i] = balls[i].radius * balls[i].radius * scale * scale;
      }
      var soft = scale * scale; // the "+ 1" in r² / (d² + 1), in screen pixels
      var idx = 0;

      for (var y = 0; y < H; y++) {
        for (i = 0; i < n; i++) dy2[i] = (y - by[i]) * (y - by[i]) + soft;
        for (var x = 0; x < W; x++) {
          var sum = 0;
          for (i = 0; i < n; i++) {
            var dx = x - bx[i];
            sum += r2[i] / (dx * dx + dy2[i]);
          }

          if (sum > 1) {
            var intensity = Math.min(sum - 1, 1) * 50; // the core gets lighter where the fields add up
            data[idx] = color[0] + intensity;
            data[idx + 1] = color[1] + intensity;
            data[idx + 2] = color[2] + intensity;
          } else {
            data[idx] = background[0];
            data[idx + 1] = background[1];
            data[idx + 2] = background[2];
          }
          data[idx + 3] = 255;
          idx += 4;
        }
      }

      ctx.putImageData(imageData, 0, 0);
    }

    function updateBalls() {
      for (var i = 0; i < balls.length; i++) {
        var ball = balls[i];
        ball.x += ball.vx;
        ball.y += ball.vy;

        if (ball.x < ball.radius) ball.vx = Math.abs(ball.vx);
        if (ball.x > w - ball.radius) ball.vx = -Math.abs(ball.vx);
        if (ball.y < ball.radius) ball.vy = Math.abs(ball.vy);
        if (ball.y > h - ball.radius) ball.vy = -Math.abs(ball.vy);
      }
      keepInside();
    }

    function animate() {
      raf = 0;
      updateBalls();
      drawMetaballs();
      start();
    }

    // The loop only runs if the block is on screen, the tab visible and motion allowed
    function start() {
      if (!raf && visible && !document.hidden && !reduce.matches) raf = requestAnimationFrame(animate);
    }

    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function(entries) {
        visible = entries[0].isIntersecting;
        if (visible) start();
        else if (raf) { cancelAnimationFrame(raf); raf = 0; }
      }).observe(root);
    }
    document.addEventListener('visibilitychange', start);

    if ('ResizeObserver' in window) new ResizeObserver(resize).observe(container);
    else window.addEventListener('resize', resize);
    resize();
  });
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Number of balls (HTML — data-balls on .fx-0391-root) 6 2 or 3 balls tell the story of a meeting; 8 or more fill a small frame with a single mass. Computing time grows with the count: 12 balls cost twice as much as 6.
Color (HTML — data-color, three numbers from 0 to 255) 99,102,241 (indigo) data-color="132,162,128" for sage green. The core gets lighter by +50 on each channel: an already very light color saturates to white there.
Background color (JS — var background = [26, 26, 36]) 26, 26, 36 This is the color of pixels outside the balls. Also change the background of .fx-0391-root so no edge shows during the first paint.
Ball size (JS — 30 + Math.random() * 40) radius from 30 to 70 px 15 + Math.random() * 20 gives 15 to 35 px drops, which merge less often. Between two balls of the same radius, the bridge of matter appears when their centers are less than 2.8 radii apart, edges still 0.8 radius apart.
Speed (JS — (Math.random() - 0.5) * 2) -1 to 1 px per frame on each axis * 1 instead of * 2 halves the speed. A slow scene reads better behind text.
Sharpness versus smoothness (JS — var PIXEL_BUDGET = 650000) 650,000 pixels computed per frame Beyond that, Retina density goes back toward 1 pixel per CSS pixel. Lower it (300,000) for a full-screen banner on modest phones; raise it for a wide, sharp block on a powerful computer.
Height (CSS — height of .fx-0391-root) 250px The canvas follows the block's height. For a banner background, set the root to position: absolute; inset: 0 inside the banner.

FAQ

Why a canvas and not a CSS "goo" filter?

The goo filter (feGaussianBlur then feColorMatrix) blurs shapes and then hardens the blur: the result looks like metaballs, but the edge depends on the blur and the background color, and the core stays flat. Here the outline is computed: it runs exactly where the field equals 1, whatever the color, and the core gets lighter where fields add up. In return, every pixel is computed on every frame: the script caps that work at 650,000 pixels and stops off screen.

How many blocks can a page hold?

The script animates every .fx-0391-root on the page, each with its own data-balls and data-color settings. Only visible blocks compute: a block scrolled off screen costs nothing. Two 640 × 250 blocks visible at the same time on a Retina screen take about 12 ms per frame in Chromium: that is the reasonable limit.

Can the balls follow the mouse?

Not in this code, which listens to no event. To make a ball follow the pointer, listen to pointermove on the root and write the position (minus getBoundingClientRect()) into balls[0].x and balls[0].y, setting its speed to 0: it will pull a bridge of matter toward the others as it passes near them.