library / interactive / mercury-metaballs
live - 60fps 1920 x 1080
mercury-metaballs.html - self-contained
<!DOCTYPE html>
<html>
<head>
<title>Mercury Metaballs</title>
<style>
    :root {
        --bg-color: #0a0a14;
        --metal-1: #6366f1;
        --metal-2: #ec4899;
        --metal-3: #22d3ee;
    }
    * { margin: 0; padding: 0; }
    body { height: 100vh; background: var(--bg-color); overflow: hidden; }
    .field {
        position: absolute;
        inset: 0;
        filter: url(#goo);
    }
    .blob {
        position: absolute;
        border-radius: 50%;
        will-change: transform;
    }
    svg { position: absolute; width: 0; height: 0; }
</style>
</head>
<body>
    <svg>
        <filter id="goo">
            <feGaussianBlur in="SourceGraphic" stdDeviation="15" result="blur" />
            <feColorMatrix in="blur" mode="matrix"
                values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9" result="goo" />
            <feBlend in="SourceGraphic" in2="goo" />
        </filter>
    </svg>
    <div class="field" id="field"></div>
<script>
    const css = getComputedStyle(document.documentElement);
    const colors = [
        (css.getPropertyValue('--metal-1') || '#6366f1').trim(),
        (css.getPropertyValue('--metal-2') || '#ec4899').trim(),
        (css.getPropertyValue('--metal-3') || '#22d3ee').trim()
    ];
    const field = document.getElementById('field');
    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const blobs = [];
    const COUNT = 9;
    for (let i = 0; i < COUNT; i++) {
        const el = document.createElement('div');
        el.className = 'blob';
        const size = 120 + Math.random() * 220;
        const col = colors[i % colors.length];
        el.style.width = el.style.height = size + 'px';
        el.style.background = `radial-gradient(circle at 35% 35%, ${col}, ${col} 55%, transparent 72%)`;
        field.appendChild(el);
        blobs.push({
            el, size,
            x: Math.random() * innerWidth, y: Math.random() * innerHeight,
            ax: Math.random() * Math.PI * 2, ay: Math.random() * Math.PI * 2,
            sx: 0.4 + Math.random() * 0.5, sy: 0.4 + Math.random() * 0.5,
            rx: innerWidth * (0.2 + Math.random() * 0.3),
            ry: innerHeight * (0.2 + Math.random() * 0.3),
            follow: i === 0
        });
    }
    const mouse = { x: innerWidth / 2, y: innerHeight / 2, active: false };
    addEventListener('mousemove', e => { mouse.x = e.clientX; mouse.y = e.clientY; mouse.active = true; });
    addEventListener('mouseleave', () => { mouse.active = false; });
    let t = 0;
    function place(b) { b.el.style.transform = `translate(${b.x - b.size / 2}px, ${b.y - b.size / 2}px)`; }
    function step() {
        t += 0.006;
        const cx = innerWidth / 2, cy = innerHeight / 2;
        for (const b of blobs) {
            if (b.follow && mouse.active) {
                b.x += (mouse.x - b.x) * 0.08;
                b.y += (mouse.y - b.y) * 0.08;
            } else {
                b.x = cx + Math.cos(b.ax + t * b.sx) * b.rx;
                b.y = cy + Math.sin(b.ay + t * b.sy) * b.ry;
            }
            place(b);
        }
    }
    if (reduce) { t = 2; blobs.forEach(b => { b.x = innerWidth / 2 + Math.cos(b.ax) * b.rx; b.y = innerHeight / 2 + Math.sin(b.ay) * b.ry; place(b); }); }
    else (function loop() { step(); requestAnimationFrame(loop); })();
</script>
</body>
</html>

About this animation

Liquid-metal blobs that merge and separate with gooey surface tension as they drift, with one glob chasing your cursor around the screen.

Source Code
Copied to clipboard