← Buttons

Hamburger to X

Tap the menu icon and its three bars morph into a close icon: the outer bars slide together, the middle one shrinks away, and they swing round into an X with a little spring. It’s a real toggle button with aria-expanded.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <button data-hamburger-to-x aria-label="Menu"></button>. Size it with font-size, colour it with color.
  3. Open and close your menu when it fires its toggle event.

Options

Add any of these attributes to the same element.

data-openStart as an X.
data-autoplayIt toggles itself every couple of seconds while in view. Handy for demos.

The toggle event tells you which way it went: e.detail.open. From code, animacious.hamburger(button) toggles it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hamburger to X: tap the menu icon and its three bars morph into a close
    // icon. The outer bars slide together, the middle one shrinks away, then
    // they swing round into an X with a little spring. Tap again to go back.
    // It's a real toggle: aria-expanded follows it.
    // Usage: <button data-hamburger-to-x aria-label="Menu" aria-controls="nav"></button>
    // Size it with font-size, colour it with color.
    // Optional: data-open (start as an X), data-autoplay (it toggles itself
    // every couple of seconds while in view; handy for demos).
    // Listen for the "toggle" event: e.detail.open is true or false.
    // Script use: animacious.hamburger(button) toggles it from code.
    var DEFAULTS = { every: 1800 };
    var GAP = 0.32; // em between bars

    var css =
      '.htx{position:relative;display:inline-grid;place-items:center;width:1.8em;height:1.8em;padding:0;border:0;background:none;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.htx-bar{position:absolute;left:50%;top:50%;width:1.1em;height:0.12em;margin:-0.06em 0 0 -0.55em;border-radius:0.06em;background:currentColor;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function place(s, open) {
      anime.utils.set(s.bars[0], { y: open ? '0em' : -GAP + 'em', rotate: open ? 45 : 0 });
      anime.utils.set(s.bars[1], { scaleX: open ? 0 : 1, opacity: open ? 0 : 1 });
      anime.utils.set(s.bars[2], { y: open ? '0em' : GAP + 'em', rotate: open ? -45 : 0 });
    }

    function setup(btn) {
      btn.classList.add('htx');
      if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
      if (!btn.getAttribute('aria-label')) btn.setAttribute('aria-label', 'Menu');
      btn.textContent = '';
      var bars = [];
      for (var i = 0; i < 3; i++) {
        var bar = document.createElement('span');
        bar.className = 'htx-bar';
        bar.setAttribute('aria-hidden', 'true');
        btn.appendChild(bar);
        bars.push(bar);
      }
      var open = btn.dataset.open !== undefined && btn.dataset.open !== 'false';
      btn._htx = { bars: bars, open: open };
      btn.setAttribute('aria-expanded', open);
      place(btn._htx, open);
    }

    function toggle(btn) {
      var s = btn._htx;
      var open = (s.open = !s.open);
      btn.setAttribute('aria-expanded', open);
      btn.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { place(s, open); return; }
      var top = s.bars[0], mid = s.bars[1], bottom = s.bars[2];
      if (open) {
        // Slide together, then swing round into an X.
        anime.animate([top, bottom], { y: '0em', duration: 180, ease: 'in(2)' });
        anime.animate(mid, { scaleX: 0, opacity: 0, duration: 180, ease: 'in(2)' });
        anime.animate(top, { rotate: 45, duration: 700, delay: 170, ease: 'outElastic(1, .55)' });
        anime.animate(bottom, { rotate: -45, duration: 700, delay: 170, ease: 'outElastic(1, .55)' });
      } else {
        // Unswing first, then spread back out, with the middle bar returning.
        anime.animate([top, bottom], { rotate: 0, duration: 200, ease: 'in(2)' });
        anime.animate(top, { y: -GAP + 'em', duration: 600, delay: 190, ease: 'outElastic(1, .6)' });
        anime.animate(bottom, { y: GAP + 'em', duration: 600, delay: 190, ease: 'outElastic(1, .6)' });
        anime.animate(mid, { scaleX: [0, 1], opacity: 1, duration: 450, delay: 220, ease: 'out(3)' });
      }
    }

    function init() {
      document.querySelectorAll('[data-hamburger-to-x]').forEach(function (btn) {
        if (btn._htx) return;
        setup(btn);
        btn.addEventListener('click', function () { toggle(btn); });
        if (btn.dataset.autoplay !== undefined && btn.dataset.autoplay !== 'false' &&
            !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
          var timer = null;
          var tick = function () {
            if (!btn.matches(':hover') && document.activeElement !== btn) toggle(btn);
            timer = setTimeout(tick, DEFAULTS.every);
          };
          new IntersectionObserver(function (entries) {
            clearTimeout(timer);
            if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
          }).observe(btn);
        }
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.hamburger = function (btn) { if (btn._htx) toggle(btn); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button id="menu-btn" data-hamburger-to-x aria-label="Menu" aria-controls="nav"></button>
<script>
  document.getElementById("menu-btn").addEventListener("toggle", (e) => {
    document.getElementById("nav").hidden = !e.detail.open;
  });
</script>