← SVG & icons

Morphing Blob

An organic blob slowly changes shape, each bump of its edge swelling and shrinking on its own time while the whole thing turns lazily. Paint it behind a heading, or clip a photo into it for a friendly, living avatar.

Hello there

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-morphing-blob to an element. The blob fills it, so give it a square-ish shape, e.g. aspect-ratio: 1.
  3. To shape a photo, add data-clip and put the image inside.

Options

Add any of these attributes to the same element.

data-colorsColours for its gradient, comma-separated. Default #a855f7,#ec4899.
data-pointsBumps round the edge. Default 6.
data-wobbleHow lumpy, from 0 (a circle) to 0.9. Default 0.3.
data-speedSeconds per change. Default 3.5.
data-clipClip the element itself into the blob, instead of painting one behind its content.

It only moves while on screen. With reduced motion it keeps one still blob shape.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Morphing Blob: an organic blob slowly changes shape, each bump of its
    // edge swelling and shrinking on its own time, while the whole thing turns
    // lazily. It sits behind the element's content, or clips the element itself
    // into a blob (lovely on a photo).
    // Usage: <div data-morphing-blob>…</div>
    //    or: <div data-morphing-blob data-clip><img src="me.jpg" alt="…"></div>
    // The blob fills the element, so give it a square-ish shape
    // (e.g. aspect-ratio: 1).
    // Optional: data-colors (two colours for its gradient, default
    // #a855f7,#ec4899), data-points (bumps round the edge, default 6),
    // data-wobble (0 to 1, how lumpy, default 0.3), data-speed (seconds per
    // change, default 3.5), data-clip (clip the element instead of painting
    // behind it).
    var DEFAULTS = { colors: ['#a855f7', '#ec4899'], points: 6, wobble: 0.3, speed: 3.5 };
    var NS = 'http://www.w3.org/2000/svg';
    var count = 0;

    var css =
      '.mb-back{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none;overflow:visible}' +
      '.mb-defs{position:absolute;width:0;height:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // A smooth closed curve through the points (Catmull-Rom as cubic Béziers).
    function curve(pts) {
      var n = pts.length, f = function (v) { return v.toFixed(4); };
      var s = 'M' + f(pts[0][0]) + ' ' + f(pts[0][1]);
      for (var i = 0; i < n; i++) {
        var p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n], p3 = pts[(i + 2) % n];
        s += 'C' + f(p1[0] + (p2[0] - p0[0]) / 6) + ' ' + f(p1[1] + (p2[1] - p0[1]) / 6) + ' ' +
          f(p2[0] - (p3[0] - p1[0]) / 6) + ' ' + f(p2[1] - (p3[1] - p1[1]) / 6) + ' ' + f(p2[0]) + ' ' + f(p2[1]);
      }
      return s + 'Z';
    }

    function setup(el) {
      var d = el.dataset;
      var id = 'mb-' + ++count;
      var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
      var n = Math.max(3, +d.points || DEFAULTS.points);
      var wobble = Math.min(0.9, Math.max(0, d.wobble !== undefined ? +d.wobble : DEFAULTS.wobble));
      var speed = (+d.speed || DEFAULTS.speed) * 1000;
      var clip = d.clip !== undefined && d.clip !== 'false';
      var R = anime.utils.random;
      // The blob is drawn in 0–1 units, so it scales with the element.
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('aria-hidden', 'true');
      var path;
      if (clip) {
        svg.setAttribute('class', 'mb-defs');
        svg.innerHTML = '<clipPath id="' + id + '" clipPathUnits="objectBoundingBox"><path/></clipPath>';
        el.style.clipPath = 'url(#' + id + ')';
        el.parentNode.insertBefore(svg, el);
      } else {
        if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
        el.style.isolation = 'isolate';
        svg.setAttribute('class', 'mb-back');
        svg.setAttribute('viewBox', '0 0 1 1');
        svg.setAttribute('preserveAspectRatio', 'none');
        svg.innerHTML = '<defs><linearGradient id="' + id + '" x1="0" y1="0" x2="1" y2="1">' +
          colors.map(function (c, i) { return '<stop offset="' + (colors.length > 1 ? i / (colors.length - 1) : 0) + '" stop-color="' + c + '"/>'; }).join('') +
          '</linearGradient></defs><path fill="url(#' + id + ')"/>';
        el.insertBefore(svg, el.firstChild);
      }
      path = svg.querySelector('path');

      var lumpy = function () { return R(1 - wobble, 1, 3); };
      var points = [];
      for (var i = 0; i < n; i++) points.push({ r: lumpy(), a: (i / n) * Math.PI * 2 + R(-0.25, 0.25, 3) / n });
      var spin = { a: R(0, 6.28, 2) };
      function render() {
        path.setAttribute('d', curve(points.map(function (p) {
          var a = p.a + spin.a, r = 0.49 * p.r;
          return [0.5 + Math.cos(a) * r, 0.5 + Math.sin(a) * r];
        })));
      }
      render();
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      // Each bump swells or shrinks to a new size on its own time.
      var anims = [];
      points.forEach(function (p, i) {
        (function next() {
          anims[i] = anime.animate(p, {
            r: lumpy(),
            duration: speed * R(0.7, 1.3, 2),
            ease: 'inOut(2)',
            onComplete: next
          });
        })();
      });
      anims.push(anime.animate(spin, { a: [spin.a, spin.a + Math.PI * 2],duration: speed * 12, ease: 'linear', loop: true }));
      var timer = anime.createTimer({ loop: true, duration: 1000, onUpdate: render });
      // Only change shape while it's on screen.
      new IntersectionObserver(function (entries) {
        var on = entries[0].isIntersecting;
        anims.concat(timer).forEach(function (a) { if (on) a.play(); else a.pause(); });
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-morphing-blob]').forEach(function (el) {
        if (el._mbb) return;
        el._mbb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-morphing-blob style="width: 320px; aspect-ratio: 1; display: grid; place-items: center; color: #fff">
  <h2>Hello</h2>
</div>

<!-- a photo in a blob: -->
<div data-morphing-blob data-clip style="width: 240px; aspect-ratio: 1">
  <img src="me.jpg" alt="Me" style="width: 100%; height: 100%; object-fit: cover">
</div>