January 1, 2027
Saint-Aubin and Les Essarts become one town
One town hall, one council, two villages. What changes for your paperwork →
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.
Updated
This effect is free — the Liquid category contains 31 effects total, including 6 free. Effect.Labs has 840 vanilla effects. Explore the category →
January 1, 2027
One town hall, one council, two villages. What changes for your paperwork →
Our fermentation
Our kefir grains turn the sugar from the fruit into fine bubbles. No added gas: the yeasts do the work, at room temperature.
× 400 magnification — yeasts at work
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).
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.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.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.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.
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.
Copy the three blocks into your page. No dependencies.
<div class="fx-0391-root">
<div class="metaballs-container">
<canvas class="metaballs-canvas"></canvas>
</div>
</div>
.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%;
}
(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();
});
})();
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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. |
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.
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.
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.