← Buttons

Gooey Button

Blobs of the button’s own colour bubble out from its edges, stretch away like thick liquid and melt back in. Some pinch off into droplets before rejoining. Hover or press it and it bubbles harder. The text stays crisp: only the background goes gooey.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-gooey-button to a button or link with a solid background colour.

Options

Add any of these attributes to the same element.

data-colorColour of the button and its blobs. Default: the button’s own background colour.
data-blobsHow many blobs at a time. Default 4.
data-reachHow far blobs travel past the edge, in pixels. Default 22.

Leave some space around it for the blobs. It only bubbles while it’s on screen, and keeps 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 Button: blobs of the button's own colour bubble out from its
    // edges, stretch away like thick liquid, and merge back in. Some drift
    // just past the edge, others pinch off into droplets before rejoining.
    // Hover or press it and it bubbles harder.
    // Usage: <button data-gooey-button>Squish me</button>
    // Optional: data-color (the button and blob colour; default the button's
    // own background colour), data-blobs (how many blobs at once, default 4),
    // data-reach (how far blobs travel past the edge in px, default 22).
    // The text stays crisp: only the background goes gooey. It bubbles only
    // while on screen, and keeps still with reduced motion.
    var DEFAULTS = { blobs: 4, reach: 22 };

    var css =
      '.gyb{position:relative;isolation:isolate;background:transparent!important;box-shadow:none!important}' +
      '.gyb-goo{position:absolute;inset:0;z-index:-1;border-radius:inherit;pointer-events:none;filter:url(#gyb-filter)}' +
      '.gyb-base{position:absolute;inset:0;border-radius:inherit;background:var(--gyb-color)}' +
      '.gyb-blob{position:absolute;left:0;top:0;border-radius:50%;background:var(--gyb-color);transform:scale(0)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // One shared filter: blur everything, then sharpen the alpha so touching
    // shapes melt into each other, then lay the original colours back on top.
    function addFilter() {
      if (document.getElementById('gyb-filter')) return;
      var holder = document.createElement('div');
      holder.setAttribute('aria-hidden', 'true');
      holder.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
      holder.innerHTML =
        '<svg width="0" height="0"><filter id="gyb-filter" x="-50%" y="-50%" width="200%" height="200%">' +
        '<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/>' +
        '<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8" result="goo"/>' +
        '<feComposite in="SourceGraphic" in2="goo" operator="atop"/></filter></svg>';
      document.body.appendChild(holder);
    }

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var count = Math.max(1, Math.min(10, +d.blobs || DEFAULTS.blobs));
      var reach = +d.reach || DEFAULTS.reach;
      var cs = getComputedStyle(el);
      var color = d.color || (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' ? cs.backgroundColor : '#6a0dad');
      el.style.setProperty('--gyb-color', color);
      el.classList.add('gyb');

      var goo = document.createElement('span');
      goo.className = 'gyb-goo';
      goo.setAttribute('aria-hidden', 'true');
      var base = document.createElement('span');
      base.className = 'gyb-base';
      goo.appendChild(base);
      el.insertBefore(goo, el.firstChild);
      if (reduced()) return;

      var blobs = [];
      for (var i = 0; i < count + 3; i++) {
        var b = document.createElement('span');
        b.className = 'gyb-blob';
        goo.appendChild(b);
        b._gybIdx = i;
        blobs.push(b);
      }

      var live = false, eager = false;
      var R = anime.utils.random;

      // A spot on the edge and the direction straight out from it. Rounded
      // ends point out sideways and diagonally; the long edges point up/down.
      function edgePoint(w, h) {
        var r = Math.min(h / 2, w / 2);
        if (Math.random() < 0.3) {
          var side = Math.random() < 0.5 ? -1 : 1;
          var ang = R(-60, 60) * Math.PI / 180;
          var cx = side < 0 ? r : w - r;
          var nx = Math.cos(ang) * side, ny = Math.sin(ang);
          return { x: cx + nx * r, y: h / 2 + ny * (h / 2), nx: nx, ny: ny };
        }
        var top = Math.random() < 0.5;
        return { x: R(r, w - r), y: top ? 0 : h, nx: R(-3, 3) / 10, ny: top ? -1 : 1 };
      }

      function bubble(b) {
        if (!live || b._gybIdx >= (eager ? count + 3 : count)) { b._gybBusy = false; return; }
        var w = el.offsetWidth, h = el.offsetHeight;
        var size = h * R(42, 62) / 100;
        var p = edgePoint(w, h);
        var far = eager ? reach * 1.35 : reach;
        // Every third blob or so pinches off into a droplet and comes back.
        var dist = Math.random() < 0.35 ? far * R(90, 120) / 100 : far * R(35, 70) / 100;
        var x0 = p.x - size / 2 - p.nx * size * 0.35, y0 = p.y - size / 2 - p.ny * size * 0.35;
        b.style.width = b.style.height = size + 'px';
        var dur = R(900, 1500) * (eager ? 0.7 : 1);
        anime.animate(b, {
          x: [x0, x0 + p.nx * dist, x0],
          y: [y0, y0 + p.ny * dist, y0],
          scale: [0.4, R(80, 105) / 100, 0.4],
          duration: dur,
          ease: 'inOut(2)',
          onComplete: function () { anime.utils.set(b, { scale: 0 }); next(b, R(100, 700)); }
        });
      }

      function next(b, delay) { setTimeout(function () { bubble(b); }, delay); }

      function fillUp() {
        var want = eager ? count + 3 : count;
        blobs.forEach(function (b, i) {
          if (i >= want || b._gybBusy || !live) return;
          b._gybBusy = true;
          next(b, R(0, 900));
        });
      }

      function excite(on) { eager = on; if (on) fillUp(); }
      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') excite(true); });
      el.addEventListener('pointerleave', function () { excite(false); });
      el.addEventListener('pointerdown', function (e) {
        excite(true);
        if (e.pointerType === 'touch') setTimeout(function () { excite(false); }, 1600);
      });
      el.addEventListener('focus', function () { excite(true); });
      el.addEventListener('blur', function () { excite(false); });

      new IntersectionObserver(function (entries) {
        live = entries[0].isIntersecting;
        if (live) fillUp();
      }).observe(el);
    }

    function init() {
      addFilter();
      document.querySelectorAll('[data-gooey-button]').forEach(function (el) {
        if (el._gyb) return;
        el._gyb = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-gooey-button>Squish me</button>

<!-- more blobs, reaching further: -->
<button data-gooey-button data-blobs="6" data-reach="28">Extra gooey</button>