Learn to code organic, blob-like animations from scratch
Metaballs are organic-looking shapes that merge and blend together smoothly when they come close to each other. They're created using mathematical field equations and are commonly seen in lava lamps, liquid simulations, and game effects.
Each metaball creates an invisible "field" of influence around it. When multiple fields overlap, they add together. Where the combined field strength exceeds a threshold, we draw a pixel. This creates the smooth blending effect.
Download the fullscreen HTML+JS file here: metaballs.htm or follow the steps below.
First, we need a canvas element to draw on:
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Make canvas fill the viewport
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
</script>
Each metaball needs position, velocity, and size:
class Metaball {
constructor(x, y, vx, vy, radius) {
this.x = x; // X position
this.y = y; // Y position
this.vx = vx; // X velocity
this.vy = vy; // Y velocity
this.radius = radius; // Size
}
update() {
// Move the ball
this.x += this.vx;
this.y += this.vy;
// Bounce off walls
if (this.x - this.radius < 0 || this.x + this.radius > canvas.width) {
this.vx *= -1;
}
if (this.y - this.radius < 0 || this.y + this.radius > canvas.height) {
this.vy *= -1;
}
}
}
This is the mathematical heart of metaballs. For each pixel, we calculate how much each ball influences it:
The closer a point is to a ball's center, the stronger the influence. We sum up all influences:
function getMetaballValue(x, y) {
let sum = 0;
// Add influence from each ball
for (const ball of balls) {
// Calculate distance squared (faster than sqrt)
const dx = x - ball.x;
const dy = y - ball.y;
const distSq = dx * dx + dy * dy;
// Add this ball's influence
sum += (ball.radius * ball.radius) / distSq;
}
return sum;
}
We use distance squared instead of actual distance because it's faster to calculate (no square root needed) and still gives us the smooth falloff we want. The inverse square relationship creates a natural-looking field.
We check every pixel and color it based on the field strength:
function render() {
const imageData = ctx.createImageData(canvas.width, canvas.height);
const data = imageData.data;
const step = 3; // Check every 3 pixels for performance
for (let y = 0; y < canvas.height; y += step) {
for (let x = 0; x < canvas.width; x += step) {
const value = getMetaballValue(x, y);
// If field is strong enough, draw a pixel
if (value > 1) {
// Color based on intensity
const intensity = Math.min(value / 3, 1);
const r = Math.floor(100 + intensity * 155);
const g = Math.floor(50 + intensity * 100);
const b = Math.floor(200 + intensity * 55);
// Fill the step x step block
for (let dy = 0; dy < step; dy++) {
for (let dx = 0; dx < step; dx++) {
const idx = ((y + dy) * canvas.width + (x + dx)) * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = 255; // Alpha
}
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
step variable to skip pixels. Checking every pixel (step=1) looks smoother but is slower. A step of 2-4 balances quality and performance.
Create an invisible metaball that follows the cursor:
const mouseInfluence = {
x: -1000,
y: -1000,
radius: 80,
active: false
};
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseInfluence.x = e.clientX - rect.left;
mouseInfluence.y = e.clientY - rect.top;
mouseInfluence.active = true;
});
canvas.addEventListener('mouseleave', () => {
mouseInfluence.active = false;
});
Then include it in the field calculation:
function getMetaballValue(x, y) {
let sum = 0;
for (const ball of balls) {
const dx = x - ball.x;
const dy = y - ball.y;
const distSq = dx * dx + dy * dy;
sum += (ball.radius * ball.radius) / distSq;
}
// Add mouse influence
if (mouseInfluence.active) {
const dx = x - mouseInfluence.x;
const dy = y - mouseInfluence.y;
const distSq = dx * dx + dy * dy;
sum += (mouseInfluence.radius * mouseInfluence.radius) / distSq;
}
return sum;
}
function animate() {
// Clear canvas
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Update all balls
for (const ball of balls) {
ball.update();
}
// Render metaballs
render();
// Loop
requestAnimationFrame(animate);
}
animate();
Add touch event handlers for mobile devices:
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
mouseInfluence.x = touch.clientX - rect.left;
mouseInfluence.y = touch.clientY - rect.top;
mouseInfluence.active = true;
});
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
balls.push(new Metaball(
e.clientX - rect.left,
e.clientY - rect.top,
(Math.random() - 0.5) * 3,
(Math.random() - 0.5) * 3,
30 + Math.random() * 40
));
});
Now that you understand the basics, try these variations: