Generative Blob Background
A living canvas metaball background where several soft blobs drift, merge and split into organic gooey shapes, following your cursor and swappable between hand-picked color palettes.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
.goo-defs {
position: absolute;
width: 0;
height: 0;
}
.stage {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
/* ---- Canvas layer ---- */
.blob-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
filter: url(#goo);
}
/* Fallback: if SVG filters are unavailable, soften with blur instead */
@supports not (filter: url(#goo)) {
.blob-canvas {
filter: blur(6px);
}
}
.stage.no-goo .blob-canvas {
filter: none;
}
/* Vignette + faint grain over the blobs for depth */
.stage::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(120% 90% at 50% 0%, transparent 55%, rgba(0, 0, 0, 0.55) 100%),
radial-gradient(100% 100% at 50% 120%, rgba(0, 0, 0, 0.5), transparent 60%);
z-index: 1;
}
/* ---- Overlay UI ---- */
.overlay {
position: relative;
z-index: 2;
height: 100%;
padding: clamp(20px, 5vw, 56px);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 24px;
pointer-events: none;
}
.hero {
max-width: 540px;
pointer-events: none;
}
.eyebrow {
display: inline-block;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(12, 13, 16, 0.5);
backdrop-filter: blur(8px);
}
.hero h1 {
margin: 16px 0 10px;
font-size: clamp(28px, 6vw, 52px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
}
.hero p {
margin: 0;
color: var(--muted);
font-size: clamp(14px, 2.2vw, 17px);
max-width: 44ch;
}
/* ---- Control panel ---- */
.panel {
pointer-events: auto;
width: 300px;
max-width: 100%;
align-self: flex-end;
background: rgba(21, 22, 27, 0.72);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
display: grid;
gap: 16px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.panel-head h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--ink);
}
.fps {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: var(--muted);
}
.control {
display: grid;
gap: 8px;
}
.control label,
.switch-label {
font-size: 13px;
font-weight: 500;
color: var(--muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.val {
font-family: "JetBrains Mono", monospace;
color: var(--ink);
font-size: 12px;
}
/* Palette swatches */
.palettes {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.swatch {
cursor: pointer;
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 4px;
background: var(--surface-2);
aspect-ratio: 1;
display: flex;
transition: border-color 0.18s ease, transform 0.18s ease;
}
.swatch:hover {
transform: translateY(-2px);
border-color: var(--line-2);
}
.swatch:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.swatch[aria-checked="true"] {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.swatch-fill {
flex: 1;
border-radius: 6px;
}
/* Range inputs */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: var(--surface-2);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
transition: transform 0.15s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--ink);
border: 3px solid var(--accent);
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 2px var(--accent-2);
}
/* Toggle switch */
.control--row {
display: flex;
align-items: center;
justify-content: space-between;
}
.switch {
position: relative;
width: 44px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--surface-2);
cursor: pointer;
padding: 0;
transition: background 0.2s ease;
flex-shrink: 0;
}
.switch[aria-checked="true"] {
background: var(--accent);
border-color: var(--accent);
}
.switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--ink);
transition: transform 0.2s ease;
}
.switch[aria-checked="true"] .switch-knob {
transform: translateX(20px);
}
.switch:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* Ghost button */
.ghost-btn {
font-family: "Inter", sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px 14px;
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.1s ease;
}
.ghost-btn:hover {
background: #262932;
border-color: var(--line-2);
}
.ghost-btn:active {
transform: translateY(1px);
}
.ghost-btn:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---- Responsive ---- */
@media (max-width: 520px) {
.overlay {
justify-content: flex-start;
}
.panel {
width: 100%;
align-self: stretch;
margin-top: auto;
}
.hero p {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}(function () {
"use strict";
var canvas = document.getElementById("blobs");
var ctx = canvas.getContext("2d");
var stage = document.querySelector(".stage");
// ---- Palettes (each: background base + blob colors) ----
var PALETTES = {
aurora: { name: "Aurora", colors: ["#8b5cf6", "#22d3ee", "#34d399", "#6366f1"] },
ember: { name: "Ember", colors: ["#f87171", "#fbbf24", "#fb7185", "#f97316"] },
lagoon: { name: "Lagoon", colors: ["#22d3ee", "#0ea5e9", "#34d399", "#2dd4bf"] },
neon: { name: "Neon", colors: ["#ec4899", "#8b5cf6", "#22d3ee", "#a3e635"] }
};
var state = {
palette: "aurora",
count: 6,
speed: 1.0,
goo: true,
reduced: window.matchMedia("(prefers-reduced-motion: reduce)").matches
};
var blobs = [];
var pointer = { x: -9999, y: -9999, active: false };
var cursorBlob = null;
var dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
var W = 0;
var H = 0;
// ---- Sizing (HiDPI aware) ----
function resize() {
dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2));
W = stage.clientWidth;
H = stage.clientHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function rand(min, max) {
return min + Math.random() * (max - min);
}
// ---- Blob factory ----
function makeBlob(colorIdx) {
var minDim = Math.min(W, H);
return {
x: rand(0.1, 0.9) * W,
y: rand(0.1, 0.9) * H,
vx: rand(-0.4, 0.4),
vy: rand(-0.4, 0.4),
r: rand(minDim * 0.14, minDim * 0.26),
colorIdx: colorIdx,
phase: rand(0, Math.PI * 2),
wobble: rand(0.6, 1.4)
};
}
function buildBlobs() {
blobs = [];
var colors = PALETTES[state.palette].colors;
for (var i = 0; i < state.count; i++) {
blobs.push(makeBlob(i % colors.length));
}
// Dedicated pointer-follow blob (last, brightest color)
cursorBlob = makeBlob(0);
cursorBlob.r = Math.min(W, H) * 0.16;
cursorBlob.isCursor = true;
}
// ---- Draw a single blob as a radial gradient ----
function drawBlob(b, colors) {
var base = colors[b.colorIdx % colors.length];
var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
g.addColorStop(0, base);
g.addColorStop(0.55, base);
g.addColorStop(1, hexToRgba(base, 0));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
}
function hexToRgba(hex, a) {
var h = hex.replace("#", "");
var r = parseInt(h.substring(0, 2), 16);
var g = parseInt(h.substring(2, 4), 16);
var bl = parseInt(h.substring(4, 6), 16);
return "rgba(" + r + "," + g + "," + bl + "," + a + ")";
}
// ---- Animation loop ----
var t = 0;
var lastFrame = performance.now();
var fpsEl = document.getElementById("fps");
var fpsAcc = 0;
var fpsCount = 0;
function tick(now) {
var dt = Math.min(now - lastFrame, 50);
lastFrame = now;
t += dt * 0.001;
// fps meter
fpsAcc += 1000 / (dt || 16);
fpsCount++;
if (fpsCount >= 20) {
fpsEl.textContent = Math.round(fpsAcc / fpsCount) + " fps";
fpsAcc = 0;
fpsCount = 0;
}
ctx.clearRect(0, 0, W, H);
var colors = PALETTES[state.palette].colors;
var move = state.reduced ? 0 : state.speed;
var minDim = Math.min(W, H);
for (var i = 0; i < blobs.length; i++) {
var b = blobs[i];
// Drift with a gentle sinusoidal wobble
b.x += (b.vx * move) + Math.sin(t * b.wobble + b.phase) * 0.25 * move;
b.y += (b.vy * move) + Math.cos(t * b.wobble + b.phase) * 0.25 * move;
// Bounce off edges with a soft margin
var m = b.r * 0.4;
if (b.x < -m) { b.x = -m; b.vx = Math.abs(b.vx); }
if (b.x > W + m) { b.x = W + m; b.vx = -Math.abs(b.vx); }
if (b.y < -m) { b.y = -m; b.vy = Math.abs(b.vy); }
if (b.y > H + m) { b.y = H + m; b.vy = -Math.abs(b.vy); }
drawBlob(b, colors);
}
// Cursor blob eases toward the pointer
if (cursorBlob) {
var tx = pointer.active ? pointer.x : W * 0.5 + Math.sin(t * 0.3) * W * 0.25;
var ty = pointer.active ? pointer.y : H * 0.5 + Math.cos(t * 0.4) * H * 0.2;
cursorBlob.x += (tx - cursorBlob.x) * 0.08;
cursorBlob.y += (ty - cursorBlob.y) * 0.08;
cursorBlob.r = minDim * (pointer.active ? 0.19 : 0.15);
drawBlob(cursorBlob, colors);
}
raf = requestAnimationFrame(tick);
}
var raf = null;
function start() {
if (raf) cancelAnimationFrame(raf);
lastFrame = performance.now();
raf = requestAnimationFrame(tick);
}
// ---- Pointer interaction ----
function toLocal(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top };
}
stage.addEventListener("pointermove", function (e) {
var p = toLocal(e.clientX, e.clientY);
pointer.x = p.x;
pointer.y = p.y;
pointer.active = true;
});
stage.addEventListener("pointerleave", function () {
pointer.active = false;
});
// ---- Controls wiring ----
// Palette swatches
var palettesEl = document.getElementById("palettes");
Object.keys(PALETTES).forEach(function (key) {
var p = PALETTES[key];
var btn = document.createElement("button");
btn.type = "button";
btn.className = "swatch";
btn.setAttribute("role", "radio");
btn.setAttribute("aria-label", p.name);
btn.setAttribute("aria-checked", key === state.palette ? "true" : "false");
btn.dataset.key = key;
var fill = document.createElement("span");
fill.className = "swatch-fill";
fill.style.background =
"linear-gradient(135deg," + p.colors[0] + "," + p.colors[1] + "," + p.colors[2] + ")";
btn.appendChild(fill);
btn.addEventListener("click", function () {
state.palette = key;
// Recolor existing blobs without rebuilding positions
var colors = p.colors;
blobs.forEach(function (b, i) {
b.colorIdx = i % colors.length;
});
Array.prototype.forEach.call(palettesEl.children, function (c) {
c.setAttribute("aria-checked", c.dataset.key === key ? "true" : "false");
});
});
palettesEl.appendChild(btn);
});
// Count
var countInput = document.getElementById("count");
var countVal = document.getElementById("countVal");
countInput.addEventListener("input", function () {
state.count = parseInt(countInput.value, 10);
countVal.textContent = state.count;
var colors = PALETTES[state.palette].colors;
// Add or trim blobs to match, preserving existing ones
while (blobs.length < state.count) blobs.push(makeBlob(blobs.length % colors.length));
while (blobs.length > state.count) blobs.pop();
});
// Speed
var speedInput = document.getElementById("speed");
var speedVal = document.getElementById("speedVal");
speedInput.addEventListener("input", function () {
state.speed = parseInt(speedInput.value, 10) / 100;
speedVal.textContent = state.speed.toFixed(1) + "x";
});
// Gooey toggle
var gooToggle = document.getElementById("gooToggle");
gooToggle.addEventListener("click", function () {
state.goo = !state.goo;
gooToggle.setAttribute("aria-checked", state.goo ? "true" : "false");
stage.classList.toggle("no-goo", !state.goo);
});
// Shuffle motion
document.getElementById("shuffle").addEventListener("click", function () {
blobs.forEach(function (b) {
b.vx = rand(-0.4, 0.4);
b.vy = rand(-0.4, 0.4);
b.phase = rand(0, Math.PI * 2);
b.wobble = rand(0.6, 1.4);
});
});
// ---- Init ----
var resizeTimer = null;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
resize();
// keep blobs in bounds after resize
blobs.forEach(function (b) {
b.x = Math.max(0, Math.min(W, b.x));
b.y = Math.max(0, Math.min(H, b.y));
});
}, 120);
});
resize();
buildBlobs();
start();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Generative Blob Background</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Gooey metaball filter (applied to the blob canvas via CSS) -->
<svg class="goo-defs" aria-hidden="true" focusable="false">
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="14" 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 26 -12"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<main class="stage">
<canvas id="blobs" class="blob-canvas" aria-hidden="true"></canvas>
<div class="overlay">
<header class="hero">
<span class="eyebrow">Canvas · Metaballs</span>
<h1>Generative Blob Background</h1>
<p>
Soft blobs drift and fuse into a gooey fluid. Move your pointer to
smear the flow, then tune the palette and physics below.
</p>
</header>
<section class="panel" aria-label="Blob background controls">
<div class="panel-head">
<h2>Controls</h2>
<span class="fps" id="fps" aria-live="off">-- fps</span>
</div>
<div class="control">
<label id="paletteLabel">Palette</label>
<div class="palettes" role="radiogroup" aria-labelledby="paletteLabel" id="palettes"></div>
</div>
<div class="control">
<label for="count">Blobs <span class="val" id="countVal">6</span></label>
<input type="range" id="count" min="3" max="12" step="1" value="6" />
</div>
<div class="control">
<label for="speed">Speed <span class="val" id="speedVal">1.0x</span></label>
<input type="range" id="speed" min="0" max="250" step="5" value="100" />
</div>
<div class="control control--row">
<span class="switch-label" id="gooLabel">Gooey threshold</span>
<button
class="switch"
id="gooToggle"
role="switch"
aria-checked="true"
aria-labelledby="gooLabel"
>
<span class="switch-knob"></span>
</button>
</div>
<button class="ghost-btn" id="shuffle" type="button">Shuffle motion</button>
</section>
</div>
</main>
<script src="script.js"></script>
</body>
</html>Generative Blob Background
A full-bleed animated background built on the HTML5 Canvas 2D API. Several blobs orbit the viewport, each drawn as a soft radial gradient. A gooey filter (blur + contrast threshold) fuses overlapping blobs into a single fluid metaball surface, so they appear to melt into and pull apart from one another like lava-lamp fluid. Every frame is driven by requestAnimationFrame and the whole scene rescales cleanly with devicePixelRatio for crisp rendering on high-density displays.
The scene is interactive: move your pointer and a dedicated cursor blob eases toward it, letting you smear and reshape the fluid in real time. A floating control panel lets you switch between curated palettes (Aurora, Ember, Lagoon, Neon), change the blob count, tune the animation speed, and toggle the gooey threshold to reveal the underlying gradient circles. All controls are keyboard accessible with visible focus states, and the demo respects prefers-reduced-motion by pausing the drift while keeping the composition intact.
This resource demonstrates layered Canvas compositing, SVG-filter-based thresholding for the metaball effect, pointer tracking with eased interpolation, and HiDPI-aware resize handling — a compact, dependency-free recipe for organic generative backgrounds.