← SVG & icons

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

  1. Paste the snippet just before </head>.
  2. Add data-gooey-merge to an empty element and give it a size. The blobs fill it.
  3. Colour them with data-color, and blend to a second colour across the blobs with data-color2.

Options

Add any of these attributes to the same element.

data-colorBlob colour. Default the text colour.
data-color2A second colour, blended diagonally across the blobs.
data-countBlobs that break away from the middle. Default 6.
data-spreadHow far they roam, from 0 to 1. Default 0.85.
data-speedHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-gooey-merge data-color="#8b5cf6" data-color2="#ec4899"
     style="width: 320px; height: 220px"></div>