← Navigation

Gooey Nav

Pick a link and the pill behind the current one melts into goo. Droplets stream across the bar, stretching and merging like liquid, and swell into a fresh pill under the link you chose.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-gooey-nav to your nav. Each child is a menu item.
  3. Give the items some padding so the pill has room round the label.

Options

Add any of these attributes to the same element.

data-colorColour of the goo. Default #6a0dad.
data-activeIndex of the item selected at first, counting from 0. Default 0.
data-dropsHow many droplets flow across. Default 5.
data-durationHow long the flow takes. Default 700 ms.
data-autoplayIt moves to the next item every couple of seconds while in view, pausing when someone clicks. Handy for demos.

The goo is an SVG blur-and-sharpen filter on a layer behind the labels, so the text stays crisp. The active item gets aria-current="page" and the class gnv-on, and a navchange event fires. Links with a real href still navigate.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Gooey Nav: the pill behind the active link melts into goo when you pick
    // another one. Droplets stream across, stretching and merging like liquid,
    // and swell into a new pill under the link you chose.
    // Usage: <nav data-gooey-nav><a href="#">Home</a><a href="#">Work</a>…</nav>
    // Every child element is a menu item. The active one gets class "gnv-on"
    // and aria-current="page"; a "navchange" event (e.detail.index) fires on a change.
    // Optional: data-color (goo colour, default #6a0dad), data-active (index
    // selected at first, default 0), data-drops (droplets per move, default 5),
    // data-duration (ms, default 700), data-autoplay (moves to the next item every
    // couple of seconds while in view; it pauses for a while when someone clicks).
    // Script use: animacious.gooeyNav(nav, index) selects an item from code.
    var DEFAULTS = { color: '#6a0dad', active: 0, drops: 5, duration: 700, every: 2000 };
    var uid = 0;

    var css =
      '.gnv{position:relative;display:inline-flex;align-items:center;isolation:isolate}' +
      '.gnv-item{position:relative;z-index:1;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .25s}' +
      '.gnv-item.gnv-on{color:#fff}' +
      '.gnv-goo{position:absolute;inset:0;z-index:0;pointer-events:none}' +
      '.gnv-blob{position:absolute;left:0;top:0;border-radius:999px;will-change:transform}' +
      '.gnv-svg{position:absolute;width:0;height:0;overflow:hidden}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function box(t) { return { x: t.offsetLeft, y: t.offsetTop, w: t.offsetWidth, h: t.offsetHeight }; }

    function placePill(p, b) {
      p.style.width = b.w + 'px';
      p.style.height = b.h + 'px';
      anime.utils.set(p, { x: b.x, y: b.y });
    }

    function mark(s, i) {
      s.items.forEach(function (t, k) {
        t.classList.toggle('gnv-on', k === i);
        if (s.a11y) { if (k === i) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }
      });
    }

    function select(el, i, instant) {
      var s = el._gnv;
      i = (i + s.items.length) % s.items.length;
      var from = s.index;
      s.index = i;
      var to = box(s.items[i]);
      var cur = s.pills[s.which];
      if (instant || from < 0 || reduced() || from === i) {
        anime.utils.remove(s.pills.concat(s.drops));
        s.pills.forEach(function (p) { anime.utils.set(p, { scale: 0 }); });
        s.drops.forEach(function (d) { anime.utils.set(d, { opacity: 0 }); });
        placePill(cur, to);
        anime.utils.set(cur, { scale: 1 });
        mark(s, i);
        return;
      }
      var old = box(s.items[from]);
      var next = s.pills[s.which = 1 - s.which];
      var dur = s.duration;
      anime.utils.remove(s.pills.concat(s.drops));
      mark(s, -1);

      // The old pill melts away where it stands.
      anime.animate(cur, { scale: 0, duration: dur * 0.75, ease: 'in(2)' });

      // Droplets run from one item to the other, beading and merging.
      var size = Math.round(old.h * 0.62);
      var y = old.y + old.h / 2 - size / 2;
      var ax = old.x + old.w / 2 - size / 2, bx = to.x + to.w / 2 - size / 2;
      var bend = (to.x > old.x ? 1 : -1) * Math.min(10, old.h * 0.28);
      s.drops.forEach(function (d, k) {
        d.style.width = d.style.height = size + 'px';
        var f = 1 - k / s.drops.length;
        anime.utils.set(d, { x: ax, y: y, scale: 0.6 + 0.4 * f, opacity: 1 });
        anime.animate(d, {
          x: bx,
          y: [y, y + (k % 2 ? bend : -bend), y],
          scale: [0.6 + 0.4 * f, 0.9, 0.3],
          duration: dur,
          delay: k * dur * 0.07,
          ease: 'inOut(3)',
          onComplete: function () { anime.utils.set(d, { opacity: 0 }); }
        });
      });

      // A new pill swells up under the chosen item as the goo arrives.
      placePill(next, to);
      anime.utils.set(next, { scale: 0 });
      anime.animate(next, { scale: [0, 1], duration: dur * 1.1, delay: dur * 0.45, ease: 'outElastic(1, .7)' });
      clearTimeout(s.labelTimer);
      s.labelTimer = setTimeout(function () { mark(s, s.index); }, dur * 0.6);
      el.dispatchEvent(new CustomEvent('navchange', { bubbles: true, detail: { index: i } }));
    }

    function setup(el) {
      var d = el.dataset;
      var items = Array.prototype.slice.call(el.children);
      if (!items.length) return;
      var color = d.color || DEFAULTS.color;
      var id = 'gooey-nav-goo-' + (++uid);
      el.classList.add('gnv');

      // The goo: everything in this layer is blurred, then the alpha is
      // sharpened again, so shapes that touch flow into one another.
      var svgNS = 'http://www.w3.org/2000/svg';
      var svg = document.createElementNS(svgNS, 'svg');
      svg.setAttribute('class', 'gnv-svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.innerHTML = '<filter id="' + id + '" x="-30%" y="-60%" width="160%" height="220%">' +
        '<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>' +
        '<feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9"/></filter>';
      var goo = document.createElement('span');
      goo.className = 'gnv-goo';
      goo.setAttribute('aria-hidden', 'true');
      goo.style.filter = 'url(#' + id + ')';
      function blob() {
        var b = document.createElement('span');
        b.className = 'gnv-blob';
        b.style.background = color;
        goo.appendChild(b);
        return b;
      }
      var pills = [blob(), blob()];
      var drops = [];
      var n = Math.max(1, +d.drops || DEFAULTS.drops);
      for (var k = 0; k < n; k++) drops.push(blob());
      el.appendChild(svg);
      el.appendChild(goo);

      var a11y = !el.closest('[aria-hidden="true"]');
      items.forEach(function (t, k) {
        t.classList.add('gnv-item');
        if (a11y && t.tagName === 'BUTTON' && !t.getAttribute('type')) t.type = 'button';
        t.addEventListener('click', function (e) {
          if (t.tagName === 'A' && t.getAttribute('href') === '#') e.preventDefault();
          select(el, k);
        });
      });
      var s = el._gnv = {
        items: items, pills: pills, drops: drops, which: 0, index: -1, a11y: a11y,
        duration: +d.duration || DEFAULTS.duration, labelTimer: null
      };
      select(el, +d.active || DEFAULTS.active, true);
      if (window.ResizeObserver) new ResizeObserver(function () { select(el, s.index, true); }).observe(el);

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var timer = null, idleUntil = 0, canHover = window.matchMedia('(hover: hover)').matches;
        var hold = function () { idleUntil = Date.now() + 8000; };
        el.addEventListener('pointerdown', hold);
        el.addEventListener('keydown', hold);
        var tick = function () {
          if (Date.now() > idleUntil && !(canHover && el.matches(':hover'))) select(el, s.index + 1);
          timer = setTimeout(tick, DEFAULTS.every);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 900);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-gooey-nav]').forEach(function (el) {
        if (el._gnv) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.gooeyNav = function (el, i) { if (el._gnv) select(el, i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav data-gooey-nav>
  <a href="#">Home</a>
  <a href="#">Work</a>
  <a href="#">Blog</a>
  <a href="#">Contact</a>
</nav>