Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Gooey metaballs canvas" for my site using the Enrichr Library element at https://enrichr.dev/elements/metaballs-canvas.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/metaballs-canvas.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- count: 8
- size: 46
- speed: 1
- detail: 6
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/metaballs-canvas.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-mbc">
<div class="ef-mbc__wrap"><canvas class="ef-mbc__cv"></canvas></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-mbc { width: 100%; }
.ef-mbc__wrap { box-sizing: border-box; position: relative; width: 100%; height: 22rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; }
.ef-mbc__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: pan-y; cursor: crosshair; outline: none; }
.ef-mbc__cv:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }JavaScript
// Enrichr Library · https://enrichr.dev
// Metaballs canvas: gooey blobs that merge and follow the pointer. Each blob adds r^2 / d^2 to a scalar field; the field is sampled on a coarse grid,
// thresholded with a soft edge into a small offscreen canvas, then upscaled with smoothing so the edges stay liquid at a fraction of the cost.
// Pauses offscreen (IntersectionObserver). Reduced motion draws one still frame. Arrow keys move the lead blob when the canvas is focused.
// mount(root, { background, accent, count (3-14), size (px radius), speed (0.2-3), detail (cell px, 3-12) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-mbc__wrap");
var cv = root.querySelector(".ef-mbc__cv");
var ctx = cv.getContext("2d");
var off = document.createElement("canvas"), octx = off.getContext("2d");
var opts = {}, balls = [], W = 0, H = 0, gw = 0, gh = 0, img = null, raf = 0, last = 0, visible = true, running = false;
var lead = { x: 0, y: 0, tx: 0, ty: 0, active: false, kb: false }, ro, io, offs = [], t0 = 0;
function hex(c) {
var m = /^#?([0-9a-f]{6})$/i.exec(c || "");
if (!m) return [230, 255, 85];
var n = parseInt(m[1], 16); return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function seed() {
balls = [];
for (var i = 0; i < opts.count; i++) {
var a = i * 2.399963 + 0.5;
balls.push({
x: W * (0.5 + 0.34 * Math.cos(a) * (0.4 + (i % 4) * 0.18)), y: H * (0.5 + 0.34 * Math.sin(a) * (0.4 + (i % 3) * 0.25)),
vx: Math.cos(a * 3) * 40, vy: Math.sin(a * 2) * 40, r: opts.size * (0.55 + ((i * 37) % 10) / 16)
});
}
lead.x = lead.tx = W / 2; lead.y = lead.ty = H / 2;
}
function size() {
var r = wrap.getBoundingClientRect();
W = Math.max(2, r.width); H = Math.max(2, r.height);
var dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
gw = Math.max(2, Math.ceil(W / opts.detail)); gh = Math.max(2, Math.ceil(H / opts.detail));
off.width = gw; off.height = gh;
img = octx.createImageData(gw, gh);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
if (!balls.length) seed();
draw();
}
function step(dt, now) {
var i, b, k = opts.speed;
if (!lead.active && !lead.kb) {
lead.tx = W * (0.5 + 0.28 * Math.sin(now * 0.00035 * k));
lead.ty = H * (0.5 + 0.26 * Math.sin(now * 0.00051 * k + 1));
}
var f = 1 - Math.exp(-dt * 7 * Math.max(0.5, k));
lead.x += (lead.tx - lead.x) * f; lead.y += (lead.ty - lead.y) * f;
for (i = 0; i < balls.length; i++) {
b = balls[i];
var dx = lead.x - b.x, dy = lead.y - b.y, d = Math.sqrt(dx * dx + dy * dy) + 1;
var pull = (lead.active || lead.kb ? 90 : 18) * k;
b.vx += dx / d * pull * dt; b.vy += dy / d * pull * dt;
b.vx *= 1 - 0.55 * dt; b.vy *= 1 - 0.55 * dt;
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy), mx = 110 * k;
if (sp > mx) { b.vx *= mx / sp; b.vy *= mx / sp; }
b.x += b.vx * dt * k; b.y += b.vy * dt * k;
if (b.x < b.r * 0.4) { b.x = b.r * 0.4; b.vx = Math.abs(b.vx); }
if (b.x > W - b.r * 0.4) { b.x = W - b.r * 0.4; b.vx = -Math.abs(b.vx); }
if (b.y < b.r * 0.4) { b.y = b.r * 0.4; b.vy = Math.abs(b.vy); }
if (b.y > H - b.r * 0.4) { b.y = H - b.r * 0.4; b.vy = -Math.abs(b.vy); }
}
}
function draw() {
if (!img) return;
var n = balls.length + 1, bx = new Float32Array(n), by = new Float32Array(n), br = new Float32Array(n), i, x, y;
for (i = 0; i < balls.length; i++) { bx[i] = balls[i].x; by[i] = balls[i].y; br[i] = balls[i].r * balls[i].r; }
bx[n - 1] = lead.x; by[n - 1] = lead.y; br[n - 1] = Math.pow(opts.size * 1.35, 2);
var cw = W / gw, ch = H / gh, data = img.data, p = 0;
var A = hex(opts.accent), E = [0, 110, 74];
for (y = 0; y < gh; y++) {
var py = (y + 0.5) * ch;
for (x = 0; x < gw; x++) {
var px = (x + 0.5) * cw, f = 0;
for (i = 0; i < n; i++) { var dx = px - bx[i], dy = py - by[i]; f += br[i] / (dx * dx + dy * dy + 1); }
var s = (f - 0.9) / 0.2; s = s < 0 ? 0 : s > 1 ? 1 : s;
var t = (f - 1) / 2.2; t = t < 0 ? 0 : t > 1 ? 1 : t; t = t * t * (3 - 2 * t);
data[p] = E[0] + (A[0] - E[0]) * t; data[p + 1] = E[1] + (A[1] - E[1]) * t; data[p + 2] = E[2] + (A[2] - E[2]) * t;
data[p + 3] = s * 255; p += 4;
}
}
octx.putImageData(img, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(off, 0, 0, gw, gh, 0, 0, cv.width, cv.height);
}
function frame(now) {
raf = 0;
if (!running) return;
var dt = Math.min(0.05, (now - last) / 1000 || 0.016); last = now;
step(dt, now - t0); draw();
raf = requestAnimationFrame(frame);
}
function play() {
if (reduce || running || !visible) return;
running = true; last = performance.now(); raf = requestAnimationFrame(frame);
}
function stop() { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; }
function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }
function toLocal(e) { var r = cv.getBoundingClientRect(); lead.tx = e.clientX - r.left; lead.ty = e.clientY - r.top; }
on(cv, "pointermove", function (e) {
if (e.pointerType === "touch") return;
lead.active = true; lead.kb = false; toLocal(e);
if (reduce) { lead.x = lead.tx; lead.y = lead.ty; draw(); }
});
on(cv, "pointerleave", function () { lead.active = false; });
on(cv, "keydown", function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
if (!lead.kb) { lead.tx = lead.x; lead.ty = lead.y; }
lead.kb = true; lead.active = false;
lead.tx = Math.max(0, Math.min(W, lead.tx + d[0] * W * 0.07)); lead.ty = Math.max(0, Math.min(H, lead.ty + d[1] * H * 0.07));
if (reduce) { lead.x = lead.tx; lead.y = lead.ty; draw(); }
});
on(cv, "blur", function () { lead.kb = false; });
cv.tabIndex = 0; cv.setAttribute("role", "img");
cv.setAttribute("aria-label", "Decorative gooey metaballs that follow the pointer. Focus and use the arrow keys to move the lead blob.");
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (en) { visible = en[en.length - 1].isIntersecting; if (visible) play(); else stop(); }, { threshold: 0.02 });
io.observe(wrap);
}
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", count: 8, size: 46, speed: 1, detail: 6 }, opts, next || {});
opts.count = Math.max(3, Math.min(14, Math.round(opts.count)));
opts.detail = Math.max(3, Math.min(12, Math.round(opts.detail)));
opts.size = Math.max(14, Math.min(120, opts.size));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
if (prev.count !== opts.count || prev.size !== opts.size) balls = [];
size();
if (!ro) {
if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(wrap); }
else on(window, "resize", size);
}
if (!running && !reduce) play();
}
t0 = performance.now();
update(o);
return { update: update, destroy: function () { stop(); if (io) io.disconnect(); if (ro) ro.disconnect(); offs.forEach(function (f) { f(); }); offs = []; } };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":8,"size":46,"speed":1,"detail":6});How it works
Each blob adds radius squared divided by distance squared to a scalar field. The script evaluates that field once per cell on a coarse grid (cell size is the detail option), turns the value into a soft-edged alpha around a threshold and into a green-to-accent colour ramp, and writes it to a small offscreen canvas. That canvas is then drawn up to full size with image smoothing on, which turns the blocky grid into liquid edges. One lead blob chases the pointer with a spring; the others are pulled towards it, drift, and bounce off the walls, so they merge and split. With no pointer the lead blob wanders on a slow looping path.
Accessibility and performance
The canvas is decorative and labelled as such, but it is focusable and the arrow keys move the lead blob so keyboard users get the same interaction. Under reduced motion the loop never starts: one still frame is drawn and the lead blob only moves when the pointer or arrow keys move it. Keep any text over it at high contrast against both the dark base and the lime blobs.
Cost scales with the number of grid cells times the number of blobs, not with canvas size, so a larger cell size is the main speed lever. The loop is paused with an IntersectionObserver when the element leaves the viewport, the canvas backing store is capped at 2x device pixel ratio, and resize is handled by a ResizeObserver. Everything is released on destroy.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Drawing 2D Metaballs with WebGL2 by Georgi Nikolov on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why not WebGL?
A fragment shader would do the same per-pixel field maths on the GPU and scale to full resolution. For a hero-sized background, evaluating the field on a coarse grid and upscaling it with smoothing in Canvas 2D is lighter to ship, has no context limits and looks just as liquid.
How do I make the edges sharper?
Lower the cell size. At 3px the grid is fine enough that the upscale smoothing is barely visible, at the cost of more field evaluations.
How do I change the colours?
The accent option sets the core colour. The edge colour is the E array in draw(), and the page background is the background option.