← Navigation

Radial Menu

Tap the round button and its items spin out from behind it, fanning into a circle round it with a little overshoot, while the plus turns into a close icon. Tap again and they spin back in.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-radial-menu to an element holding your items (links or buttons). Style them as small round buttons.
  3. The script adds the main button in the middle; style it with .rdm-btn.

Options

Add any of these attributes to the same element.

data-radiusDistance from the centre to each item, in pixels. Default 80.
data-angleHow much of the circle to use, in degrees. Try 180 for a half fan. Default 360.
data-startAngle of the first item: 0 is right, −90 is up. Default −90.
data-labelAccessible name for the main button. Default “Open menu”.
data-autoplayIt opens and closes itself while in view. Handy for demos.

Leave room round it for the items. Give icon-only items an aria-label. Escape or picking an item closes it, and it fires a toggle event (e.detail.open).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Radial Menu: tap the round button and its items spin out from behind it,
    // fanning into a circle round it, while the plus turns into a close icon.
    // Tap again and they spin back in.
    // Usage: <nav data-radial-menu aria-label="Share">
    //          <a href="#" aria-label="Email"><svg>…</svg></a>
    //          <a href="#" aria-label="Copy link"><svg>…</svg></a> …
    //        </nav>
    // Each child is an item; style them as small round buttons. The script adds
    // the main button in the middle (style it with the class "rdm-btn").
    // Optional: data-radius (px from the centre to each item, default 80),
    // data-angle (how much of the circle to use in degrees, default 360; try
    // 180 for a half fan), data-start (angle of the first item, 0 is right and
    // -90 is up; default -90), data-autoplay (it opens and closes itself while in
    // view; handy for demos).
    // Escape closes it. Listen for the "toggle" event: e.detail.open is true or false.
    // Script use: animacious.radialMenu(nav) opens or closes it.
    var DEFAULTS = { radius: 80, angle: 360, start: -90 };

    var css =
      '.rdm{position:relative;display:inline-grid;place-items:center}' +
      '.rdm-btn{position:relative;z-index:2;display:grid;place-items:center;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:#6a0dad;color:#fff;cursor:pointer;-webkit-tap-highlight-color:transparent;box-shadow:0 10px 24px rgba(106,13,173,.35)}' +
      '.rdm-plus{position:relative;display:block;width:40%;height:40%;pointer-events:none}' +
      '.rdm-plus::before,.rdm-plus::after{content:"";position:absolute;left:0;top:50%;width:100%;height:2.5px;margin-top:-1.25px;border-radius:2px;background:currentColor}' +
      '.rdm-plus::after{transform:rotate(90deg)}' +
      '.rdm-item{position:absolute;z-index:1;left:50%;top:50%;translate:-50% -50%;visibility:hidden}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function spots(s) {
      var n = s.items.length;
      var full = s.angle >= 360;
      var step = n > 1 ? s.angle / (full ? n : n - 1) : 0;
      return s.items.map(function (it, k) {
        var a = (s.start + step * k) * Math.PI / 180;
        return { x: Math.round(Math.cos(a) * s.radius), y: Math.round(Math.sin(a) * s.radius) };
      });
    }

    function toggle(el) {
      var s = el._rdm;
      var open = (s.open = !s.open);
      el.classList.toggle('rdm-open', open);
      if (s.a11y) {
        s.btn.setAttribute('aria-expanded', open);
        s.items.forEach(function (it) { it.tabIndex = open ? 0 : -1; });
      }
      el.dispatchEvent(new CustomEvent('toggle', { bubbles: true, detail: { open: open } }));
      var still = reduced();
      var at = spots(s);
      anime.utils.remove(s.items.concat([s.plus]));
      if (open) s.items.forEach(function (it) { it.style.visibility = 'visible'; });
      if (still) {
        s.items.forEach(function (it, k) {
          anime.utils.set(it, open ? { x: at[k].x, y: at[k].y, scale: 1, rotate: 0, opacity: 1 } : { x: 0, y: 0, scale: 0, opacity: 0 });
          if (!open) it.style.visibility = 'hidden';
        });
        anime.utils.set(s.plus, { rotate: open ? 135 : 0 });
        return;
      }
      anime.animate(s.plus, { rotate: open ? 135 : 0, duration: 520, ease: open ? 'outBack(2)' : 'out(3)' });
      anime.animate(s.btn, { scale: [0.88, 1], duration: 500, ease: 'outElastic(1, .5)' });
      if (open) {
        anime.utils.set(s.items, { x: 0, y: 0 });
        anime.animate(s.items, {
          x: function (it, k) { return at[k].x; },
          y: function (it, k) { return at[k].y; },
          rotate: [-180, 0],
          scale: [0.2, 1],
          opacity: [0, 1],
          duration: 560,
          delay: anime.stagger(45),
          ease: 'outBack(1.6)'
        });
      } else {
        anime.animate(s.items, {
          x: 0, y: 0, rotate: -180, scale: 0.2, opacity: 0,
          duration: 320,
          delay: anime.stagger(30, { from: 'last' }),
          ease: 'in(3)',
          onComplete: function () { if (!s.open) s.items.forEach(function (it) { it.style.visibility = 'hidden'; }); }
        });
      }
    }

    function setup(el) {
      var d = el.dataset;
      var items = Array.prototype.slice.call(el.children);
      if (!items.length) return;
      var a11y = !el.closest('[aria-hidden="true"]');
      el.classList.add('rdm');
      var btn = document.createElement(a11y ? 'button' : 'span');
      btn.className = 'rdm-btn';
      var plus = document.createElement('span');
      plus.className = 'rdm-plus';
      plus.setAttribute('aria-hidden', 'true');
      btn.appendChild(plus);
      if (a11y) {
        btn.type = 'button';
        btn.setAttribute('aria-label', d.label || 'Open menu');
        btn.setAttribute('aria-expanded', 'false');
      }
      items.forEach(function (it) {
        it.classList.add('rdm-item');
        if (a11y) it.tabIndex = -1;
      });
      el.appendChild(btn);
      var s = el._rdm = {
        btn: btn, plus: plus, items: items, open: false, a11y: a11y,
        radius: +d.radius || DEFAULTS.radius,
        angle: d.angle !== undefined ? +d.angle : DEFAULTS.angle,
        start: d.start !== undefined ? +d.start : DEFAULTS.start
      };
      anime.utils.set(items, { x: 0, y: 0, scale: 0, opacity: 0 });

      btn.addEventListener('click', function () { toggle(el); });
      items.forEach(function (it) {
        it.addEventListener('click', function (e) {
          if (it.tagName === 'A' && it.getAttribute('href') === '#') e.preventDefault();
          if (s.open) toggle(el);
        });
      });
      el.addEventListener('keydown', function (e) {
        if (e.key === 'Escape' && s.open) { toggle(el); btn.focus(); }
      });

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var timer = null, idleUntil = 0, canHover = window.matchMedia('(hover: hover)').matches;
        el.addEventListener('pointerdown', function () { idleUntil = Date.now() + 8000; });
        var tick = function () {
          if (Date.now() > idleUntil && !(canHover && el.matches(':hover')) && !el.contains(document.activeElement)) toggle(el);
          timer = setTimeout(tick, s.open ? 2200 : 1300);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-radial-menu]').forEach(function (el) {
        if (el._rdm) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.radialMenu = function (el) { if (el._rdm) toggle(el); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav data-radial-menu aria-label="Share">
  <a href="#" aria-label="Email"><svg>…</svg></a>
  <a href="#" aria-label="Copy link"><svg>…</svg></a>
  <a href="#" aria-label="Like"><svg>…</svg></a>
</nav>

<!-- a half fan above the button: -->
<nav data-radial-menu data-angle="180" data-start="-180" data-radius="96">…</nav>