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
- Paste the snippet just before
</head>. - Add
data-radial-menuto an element holding your items (links or buttons). Style them as small round buttons. - 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-radius | Distance from the centre to each item, in pixels. Default 80. |
data-angle | How much of the circle to use, in degrees. Try 180 for a half fan. Default 360. |
data-start | Angle of the first item: 0 is right, −90 is up. Default −90. |
data-label | Accessible name for the main button. Default “Open menu”. |
data-autoplay | It 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).
<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><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>