← SVG & icons

Icon State Morph

Press the button and its icon morphs into its other state: play to pause, plus to close, menu to back arrow, sound on to muted. The lines bend into their new places rather than cross-fading, with a little turn or squeeze for character.

Play / pause
Add / close
Menu / back
Sound / mute

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-icon-state-morph to an empty <button>, set to the icon you want: play-pause, plus-close, menu-arrow or sound-mute.

Options

Add any of these attributes to the same element.

data-onStart in the second state (pause, close, back, muted).
data-labelsThe button’s two labels for screen readers, comma-separated. Defaults suit each icon, e.g. Play,Pause.
data-autoplayToggle every couple of seconds while in view. Handy for demos.

Size it with font-size and colour it with color. Its aria-label follows the state. Listen for the toggle event (e.detail.on) to play or pause your media, or call animacious.iconMorph(button) to change it from code.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Icon State Morph: a button whose icon morphs smoothly between two states
    // when pressed: play ↔ pause, plus ↔ close, menu ↔ back arrow and sound
    // on ↔ muted. The lines and shapes bend into their new places (no
    // cross-fade), with a little turn or squeeze for character.
    // Its label for screen readers follows the state.
    // Usage: <button data-icon-state-morph="play-pause"></button>
    //   Icons: play-pause (default), plus-close, menu-arrow, sound-mute.
    // Size it with font-size, colour it with color.
    // Optional: data-on (start in the second state), data-labels (the two
    // labels, e.g. "Play,Pause"), data-autoplay (it toggles itself every
    // couple of seconds while in view; handy for demos).
    // Listen for the "toggle" event: e.detail.on is true in the second state.
    // Script use: animacious.iconMorph(button) toggles it from code.
    var DEFAULTS = { every: 1700 };

    // Each icon: its paths in both states (same commands, so they morph point
    // for point), how far it turns on the way, and its two labels.
    var ICONS = {
      'play-pause': {
        paths: [
          { fill: true, d: ['M7 4 L13 7.7 L13 16.3 L7 20 Z', 'M6 5 L10 5 L10 19 L6 19 Z'] },
          { fill: true, d: ['M13 7.7 L19.5 12 L19.5 12 L13 16.3 Z', 'M14 5 L18 5 L18 19 L14 19 Z'] }
        ],
        turn: 0, labels: ['Play', 'Pause']
      },
      'plus-close': {
        paths: [
          { d: ['M12 5 L12 19', 'M12 4.5 L12 19.5'] },
          { d: ['M5 12 L19 12', 'M4.5 12 L19.5 12'] }
        ],
        turn: 135, labels: ['Add', 'Close']
      },
      'menu-arrow': {
        paths: [
          { d: ['M4 6 L20 6', 'M12.5 5 L19.5 12'] },
          { d: ['M4 12 L20 12', 'M4.5 12 L19.5 12'] },
          { d: ['M4 18 L20 18', 'M12.5 19 L19.5 12'] }
        ],
        turn: 180, labels: ['Menu', 'Back']
      },
      'sound-mute': {
        paths: [
          { fill: true, d: ['M3 9.5 L7 9.5 L12 5 L12 19 L7 14.5 L3 14.5 Z', 'M3 9.5 L7 9.5 L12 5 L12 19 L7 14.5 L3 14.5 Z'] },
          { d: ['M15.5 9 Q17.5 12 15.5 15', 'M16 9 Q18.5 12 21 15'] },
          { d: ['M18.5 6 Q23 12 18.5 18', 'M21 9 Q18.5 12 16 15'] }
        ],
        turn: 0, labels: ['Mute', 'Unmute']
      }
    };

    var css =
      '.ism{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}' +
      '.ism svg{display:block;width:1.2em;height:1.2em;overflow:visible;pointer-events:none}' +
      '.ism path{stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(btn) {
      var icon = ICONS[btn.getAttribute('data-icon-state-morph')] || ICONS['play-pause'];
      var on = btn.dataset.on !== undefined && btn.dataset.on !== 'false';
      btn.classList.add('ism');
      if (btn.tagName === 'BUTTON' && !btn.getAttribute('type')) btn.type = 'button';
      btn.textContent = '';
      var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('viewBox', '0 0 24 24');
      svg.setAttribute('aria-hidden', 'true');
      var paths = icon.paths.map(function (p) {
        var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', p.d[on ? 1 : 0]);
        path.setAttribute('fill', p.fill ? 'currentColor' : 'none');
        // A thin stroke on the solid shapes softens their corners.
        if (p.fill) path.style.strokeWidth = 1.2;
        svg.appendChild(path);
        return path;
      });
      btn.appendChild(svg);
      var labels = btn.dataset.labels ? btn.dataset.labels.split(',') : icon.labels;
      btn._ism = { icon: icon, svg: svg, paths: paths, on: on, labels: labels };
      btn.setAttribute('aria-label', labels[on ? 1 : 0].trim());
      if (icon.turn && on) anime.utils.set(svg, { rotate: icon.turn });
    }

    function toggle(btn) {
      var s = btn._ism;
      var on = (s.on = !s.on);
      btn.setAttribute('aria-label', s.labels[on ? 1 : 0].trim());
      btn.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { on: on } }));
      var i = on ? 1 : 0;
      if (reduced()) {
        s.paths.forEach(function (p, n) { p.setAttribute('d', s.icon.paths[n].d[i]); });
        anime.utils.set(s.svg, { rotate: on ? s.icon.turn : 0 });
        return;
      }
      s.paths.forEach(function (p, n) {
        anime.animate(p, { d: s.icon.paths[n].d[i], duration: 420, delay: n * 40, ease: 'inOut(3)' });
      });
      if (s.icon.turn) {
        // Turn forwards into the second state and back out of it, with a spring.
        anime.animate(s.svg, { rotate: on ? s.icon.turn : 0, duration: 800, ease: 'outElastic(1, .6)' });
      } else {
        // No turn for these: a quick squeeze instead.
        anime.animate(s.svg, { scale: [{ to: 0.78, duration: 140, ease: 'out(2)' }, { to: 1, duration: 600, ease: 'outElastic(1, .5)' }] });
      }
    }

    function init() {
      document.querySelectorAll('[data-icon-state-morph]').forEach(function (btn) {
        if (btn._ism) return;
        setup(btn);
        btn.addEventListener('click', function () { toggle(btn); });
        if (btn.dataset.autoplay !== undefined && btn.dataset.autoplay !== 'false' && !reduced()) {
          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 + Math.random() * 600);
          }).observe(btn);
        }
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.iconMorph = function (btn) { if (btn._ism) toggle(btn); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button data-icon-state-morph="play-pause"></button>
<script>
  document.querySelector("[data-icon-state-morph]").addEventListener("toggle", (e) => {
    e.detail.on ? video.play() : video.pause();
  });
</script>