/* Radial action menu. The round button (aria-expanded) fans five links out on a half circle: each link is rotated by
its --a angle, pushed out 104px and rotated back so the icon stays upright; --i staggers them. Closed links are
visibility:hidden, so they drop out of the tab order. Esc, a click outside or leaving with Tab closes the fan. */
.rad{--rad-accent:#4f46e5;--rad-ink:#101828;--rad-text:#475467;--rad-radius:104px;--rad-bounce:cubic-bezier(.34,1.3,.5,1);
position:relative;display:grid;place-items:center;width:min(340px,100%);height:300px}
.rad-fab{position:relative;z-index:5;display:grid;place-items:center;width:64px;height:64px;padding:0;border:0;border-radius:50%;
background:var(--rad-accent);color:#fff;cursor:pointer;box-shadow:0 12px 30px -10px rgba(79,70,229,.85);transition:background-color .18s}
.rad-fab:hover{background:#4338ca}
.rad-fab:focus-visible{outline:2px solid var(--rad-accent);outline-offset:4px}
.rad-fab svg{display:block;width:24px;height:24px;stroke:currentColor;fill:none;stroke-width:2.4;transition:transform .3s}
.rad-fab[aria-expanded="true"] svg{transform:rotate(135deg)}
.rad-nav{position:absolute;inset:0}
.rad-nav ul{margin:0;padding:0;list-style:none}
.rad-act{position:absolute;top:50%;left:50%;display:grid;place-items:center;width:48px;height:48px;margin:-24px 0 0 -24px;border-radius:50%;
background:#fff;color:var(--rad-text);border:1px solid #e4e7ec;box-shadow:0 8px 22px -10px rgba(16,24,40,.4);
opacity:0;visibility:hidden;transform:rotate(var(--a)) translate(0) rotate(calc(-1*var(--a))) scale(.4);
transition:transform .34s var(--rad-bounce) calc(var(--i)*.045s),opacity .22s calc(var(--i)*.045s),visibility 0s .4s,
background-color .15s,color .15s,border-color .15s}
.rad-fab[aria-expanded="true"]+.rad-nav .rad-act{opacity:1;visibility:visible;transition-delay:calc(var(--i)*.045s),calc(var(--i)*.045s),0s,0s,0s,0s;
transform:rotate(var(--a)) translate(var(--rad-radius)) rotate(calc(-1*var(--a))) scale(1)}
.rad-act:hover{background:var(--rad-ink);color:#fff;border-color:var(--rad-ink)}
.rad-act:focus-visible{outline:2px solid var(--rad-accent);outline-offset:3px}
.rad-act svg{display:block;width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2}
@media (prefers-reduced-motion:reduce){.rad-fab,.rad-fab svg,.rad-act,.rad-fab[aria-expanded="true"]+.rad-nav .rad-act{transition:none}}
JavaScript
// Toggle the fan with the button; Esc closes it and returns focus to the button. A click outside, choosing an
// action, or tabbing out of the menu also closes it.
document.querySelectorAll('.rad').forEach(rad => {
const fab = rad.querySelector('.rad-fab');
const set = open => fab.setAttribute('aria-expanded', open);
fab.addEventListener('click', () => set(fab.getAttribute('aria-expanded') !== 'true'));
rad.addEventListener('keydown', e => {
if (e.key === 'Escape' && fab.getAttribute('aria-expanded') === 'true') { set(false); fab.focus(); }
});
rad.addEventListener('click', e => { if (e.target.closest('.rad-act')) set(false); });
rad.addEventListener('focusout', e => { if (e.relatedTarget && !rad.contains(e.relatedTarget)) set(false); });
document.addEventListener('click', e => { if (!rad.contains(e.target)) set(false); });
});
// Toggle the fan with the button (data-open on the wrapper drives the group-data-open: utilities); Esc closes it and returns focus to the button. A click outside, choosing an
// action, or tabbing out of the menu also closes it.
document.querySelectorAll('[data-rad]').forEach(rad => {
const fab = rad.querySelector('button[aria-controls]');
const set = open => { fab.setAttribute('aria-expanded', open); rad.toggleAttribute('data-open', open); };
fab.addEventListener('click', () => set(fab.getAttribute('aria-expanded') !== 'true'));
rad.addEventListener('keydown', e => {
if (e.key === 'Escape' && fab.getAttribute('aria-expanded') === 'true') { set(false); fab.focus(); }
});
rad.addEventListener('click', e => { if (e.target.closest('nav a')) set(false); });
rad.addEventListener('focusout', e => { if (e.relatedTarget && !rad.contains(e.relatedTarget)) set(false); });
document.addEventListener('click', e => { if (!rad.contains(e.target)) set(false); });
});
About this set
A round indigo action button that fans five circular shortcuts out along a half circle above it: New document, Upload file, Invite people, Start a call and Schedule an event. The plus icon turns into a cross as the actions spring out one after another with a slight overshoot. Use it as a floating quick-create button in a dashboard, a whiteboard or a mobile web app, where a handful of actions should be one tap away without a toolbar. Each action is placed with a single transform: rotate by its –a angle, translate 104px, then rotate back so the icon stays upright, while –i sets its stagger delay. Changing the angles or the radius reshapes the fan. The trigger is a real button that reports aria-expanded, and the actions are named links in a list inside a labelled nav. While the fan is closed the actions are visibility:hidden, so they cannot be reached with Tab. Esc closes the fan and returns focus to the button, and choosing an action, clicking outside or tabbing away also closes it. The Bootstrap version runs the same fan on a Dropdown, which adds arrow-key movement between the actions.
What’s included
Five actions fanned on a half circle by one transform each
Staggered, springy entrance and a plus that turns into a cross
Closed actions removed from the tab order
Esc closes the fan and returns focus to the button
Bootstrap version uses the Dropdown plugin with arrow-key support
Accessibility
The toggle is a button named Quick actions that reports aria-expanded, and the icon-only actions are links with aria-label names in a list inside a nav labelled Quick actions. Hidden actions are out of the tab order, Esc closes the fan and returns focus to the button, and every control shows an indigo focus outline. Transitions switch off under prefers-reduced-motion.
Customise it
Change --rad-accent, --rad-ink and --rad-radius on .rad, and each action's --a angle to reshape the fan (--i sets the stagger order). In Tailwind the radius is the translate(104px) inside the group-data-open: transform utility.