Gooey Merge
Round blobs bud off a central blob, drift away on sticky necks that stretch and snap, then roll back and melt into it again, like drops of liquid. It’s the classic “goo” SVG filter: blur the shapes, then sharpen the blur’s edge.
How to use it
- Paste the snippet just before
</head>. - Add
data-gooey-mergeto an empty element and give it a size. The blobs fill it. - Colour them with
data-color, and blend to a second colour across the blobs withdata-color2.
Options
Add any of these attributes to the same element.
data-color | Blob colour. Default the text colour. |
data-color2 | A second colour, blended diagonally across the blobs. |
data-count | Blobs that break away from the middle. Default 6. |
data-spread | How far they roam, from 0 to 1. Default 0.85. |
data-speed | How fast they move. Default 1. |
It’s decorative, so it’s hidden from screen readers. It pauses while off screen, and holds still with reduced motion.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gooey Merge: round blobs drift out of a central blob and sink back into
// it, stretching into sticky necks as they part and melting together as
// they meet, like drops of liquid. It's done with a "goo" SVG filter: blur
// the shapes, then sharpen the edges of the blur.
// Usage: <div data-gooey-merge style="width: 320px; height: 220px"></div>
// Give it a size; the blobs fill it.
// Optional: data-color (blob colour, default the text colour), data-color2
// (a second colour to blend to across the blobs), data-count (blobs that
// break away, default 6), data-speed (default 1), data-spread (how far they
// roam, 0 to 1, default 0.85).
var DEFAULTS = { count: 6, speed: 1, spread: 0.85 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'.gom{position:relative}' +
'.gom-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function setup(el) {
var d = el.dataset;
var id = 'gom-' + ++uid;
el.classList.add('gom');
el.setAttribute('aria-hidden', 'true');
var svg = make('svg', { class: 'gom-svg' }, el);
var defs = make('defs', {}, svg);
var filter = make('filter', { id: id, x: '-30%', y: '-30%', width: '160%', height: '160%', 'color-interpolation-filters': 'sRGB' }, defs);
var blur = make('feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 8 }, filter);
// Multiply alpha by 22 and pull it down by 9: the soft blur becomes a crisp, gooey edge.
make('feColorMatrix', { values: '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9' }, filter);
var fill = d.color || 'currentColor';
var grad = null;
if (d.color2) {
grad = make('linearGradient', { id: id + '-g', gradientUnits: 'userSpaceOnUse' }, defs);
make('stop', { offset: 0, 'stop-color': fill }, grad);
make('stop', { offset: 1, 'stop-color': d.color2 }, grad);
fill = 'url(#' + id + '-g)';
}
var g = make('g', { filter: 'url(#' + id + ')', fill: fill }, svg);
var n = Math.max(1, +d.count || DEFAULTS.count);
var blobs = [{ el: make('circle', {}, g), main: true }];
for (var i = 0; i < n; i++) {
blobs.push({
el: make('circle', {}, g),
a: (i / n) * Math.PI * 2 + Math.random() * 0.6,
spin: (0.08 + Math.random() * 0.12) * (i % 2 ? -1 : 1),
ph: Math.random() * Math.PI * 2,
rate: 0.45 + Math.random() * 0.35,
size: 0.42 + Math.random() * 0.3
});
}
var o = { el: el, svg: svg, blur: blur, grad: grad, blobs: blobs, speed: +d.speed || DEFAULTS.speed,
spread: d.spread !== undefined ? +d.spread : DEFAULTS.spread, w: 0, h: 0 };
size(o);
return o;
}
function size(o) {
o.w = o.el.clientWidth || 200;
o.h = o.el.clientHeight || 200;
o.r = Math.min(o.w, o.h) * 0.2;
o.svg.setAttribute('viewBox', '0 0 ' + o.w + ' ' + o.h);
o.blur.setAttribute('stdDeviation', (o.r * 0.32).toFixed(1));
if (o.grad) {
o.grad.setAttribute('x1', o.w * 0.15); o.grad.setAttribute('y1', o.h * 0.1);
o.grad.setAttribute('x2', o.w * 0.85); o.grad.setAttribute('y2', o.h * 0.9);
}
}
function draw(o, t) {
var cx = o.w / 2, cy = o.h / 2;
// How far a blob can go: most of the way to the edge, leaving room for itself.
var ex = Math.max(0, o.w / 2 - o.r * 0.7) * o.spread, ey = Math.max(0, o.h / 2 - o.r * 0.7) * o.spread;
o.blobs.forEach(function (b) {
if (b.main) {
b.el.setAttribute('cx', cx + Math.sin(t * 0.7) * o.r * 0.15);
b.el.setAttribute('cy', cy + Math.cos(t * 0.55) * o.r * 0.12);
b.el.setAttribute('r', (o.r * (1 + 0.08 * Math.sin(t * 1.3))).toFixed(1));
return;
}
// Out and back: 0 is inside the main blob, 1 is as far as it goes.
var out = Math.pow(0.5 - 0.5 * Math.cos(b.ph + t * b.rate), 1.4);
var a = b.a + t * b.spin;
b.el.setAttribute('cx', (cx + Math.cos(a) * ex * out).toFixed(1));
b.el.setAttribute('cy', (cy + Math.sin(a) * ey * out).toFixed(1));
b.el.setAttribute('r', (o.r * b.size * (1 - out * 0.25)).toFixed(1));
});
}
function start(o) {
var visible = false, raf = 0, last = 0, t = Math.random() * 10;
var frame = function (now) {
raf = 0;
if (!visible) return;
t += Math.min(0.05, (now - (last || now)) / 1000) * o.speed;
last = now;
draw(o, t);
raf = requestAnimationFrame(frame);
};
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
last = 0;
if (visible && !raf) raf = requestAnimationFrame(frame);
}).observe(o.el);
new ResizeObserver(function () { size(o); draw(o, t); }).observe(o.el);
}
function init() {
document.querySelectorAll('[data-gooey-merge]').forEach(function (el) {
if (el._gom) return;
var o = (el._gom = setup(el));
draw(o, 2.2);
if (!reduced()) start(o);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-gooey-merge data-color="#8b5cf6" data-color2="#ec4899"
style="width: 320px; height: 220px"></div>