Dock
UIA macOS-style dock: the tile under the pointer swells, its neighbours swell less, and the row pushes apart to make room.
Components / UI
A round button whose items fan out around it in a ring or an arc. The menu is a popover the button opens, so it opens and closes, and closes on Escape or a click outside, with no script; anchor positioning centres the ring on the button, and without it the ring opens in the middle of the screen. Each item is a link or a button with a name, placed on the ring at build, growing out from the centre one after another. A few lines of script move the focus around the ring with the arrow keys and put it on the first item as the menu opens.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
743 B raw
448 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.4 kB raw.
CSS 6.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 125 · Firefox 147 · Safari 26; needs popover, anchor, starting-style. Elsewhere: without anchor positioning the ring opens in the middle of the screen; without @starting-style the items appear at once.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import RadialMenu from '@moonarc/core/RadialMenu';
---
<RadialMenu
label="Share this page"
spread={180}
from={-90}
items={[
{ label: 'Copy the link', action: 'copy' },
{ label: 'Email', href: 'mailto:?subject=A page for you' },
{ label: 'Print', action: 'print' },
]}
/>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/radial-menu.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* RadialMenu — a round button whose items fan out around it in a ring or an arc: the menu is a popover the button opens,
* so it opens and closes, and closes on Escape or a click outside, with no script, and anchor positioning centres the
* ring on the button (without it, the ring opens in the middle of the screen). Each item is a link or a button with a
* name, placed on the ring at build; they grow out from the centre one after another. A few lines of script move the
* focus around the ring with the arrow keys and put it on the first item as the menu opens.
*/
import type { HTMLAttributes } from 'astro/types';
interface Item {
label: string;
/** A link; without one the item is a button that sends `ma:radial` with its `action`. */
href?: string;
action?: string;
/** An icon: the d of one SVG path in a 24 by 24 box, stroked. Without one the item shows its label's first letter. */
icon?: string;
}
interface Props extends HTMLAttributes<'div'> {
items: Item[];
/** The button's accessible name. */
label: string;
/** How far the items sit from the centre, a CSS length. */
radius?: string;
/** Where the first item sits, in degrees from straight up, clockwise. */
from?: number;
/** How much of the ring the items take, in degrees: 360 for a full ring, 180 for half. */
spread?: number;
}
const { items, label, radius = '5.5rem', from = 0, spread = 360, class: className, style, ...rest } = Astro.props;
if (items.length < 2 || items.length > 8) throw new Error(`RadialMenu: items holds ${items.length}; it fans two to eight.`);
const uid = `ma-radial-${Math.random().toString(36).slice(2, 7)}`;
const step = spread >= 360 ? 360 / items.length : spread / (items.length - 1);
const place = (i: number) => {
const a = ((from + i * step) * Math.PI) / 180;
return `--x:${+Math.sin(a).toFixed(4)};--y:${+(-Math.cos(a)).toFixed(4)};--i:${i}`;
};
const vars = [`--ma-radial-radius:${radius}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-radial', className]} data-ma-radial style={vars} {...rest}>
<button class="ma-radial__button" type="button" popovertarget={uid} aria-label={label} style={`anchor-name:--${uid}`}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</button>
<div class="ma-radial__menu" id={uid} popover style={`position-anchor:--${uid}`}>
<ul class="ma-radial__items" aria-label={label}>
{items.map((it, i) => {
const Tag = it.href ? 'a' : 'button';
return (
<li class="ma-radial__item" style={place(i)}>
<Tag class="ma-radial__link" href={it.href} type={it.href ? undefined : 'button'} data-action={it.action} aria-label={it.label} title={it.label}>
{it.icon ? <svg viewBox="0 0 24 24" aria-hidden="true"><path d={it.icon} /></svg> : <span aria-hidden="true">{it.label.slice(0, 1)}</span>}
</Tag>
</li>
);
})}
</ul>
</div>
</div>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-radial]', (root, { signal }) => {
const menu = root.querySelector<HTMLElement>('[popover]')!;
const links = [...menu.querySelectorAll<HTMLElement>('.ma-radial__link')];
menu.addEventListener('toggle', (e) => { if ((e as ToggleEvent).newState === 'open') links[0]?.focus(); }, { signal });
menu.addEventListener('keydown', (e) => {
const at = links.indexOf(document.activeElement as HTMLElement);
const go = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (go) { e.preventDefault(); links[(at + go + links.length) % links.length]!.focus(); }
else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); links[e.key === 'Home' ? 0 : links.length - 1]!.focus(); }
}, { signal });
menu.addEventListener('click', (e) => {
const b = (e.target as Element).closest<HTMLElement>('button[data-action]');
if (!b) return;
root.dispatchEvent(new CustomEvent('ma:radial', { bubbles: true, detail: { action: b.dataset.action } }));
menu.hidePopover();
}, { signal });
});
</script>
<style is:global>
@layer components {
:where(.ma-radial) { --ma-radial-radius: 5.5rem; display: inline-grid; }
:where(.ma-radial__button) { display: grid; place-items: center; inline-size: 3.25rem; block-size: 3.25rem; padding: 0; border: 0; border-radius: 999px; background: var(--primary, var(--ma-ink)); color: var(--primary-foreground, var(--ma-panel)); cursor: pointer; transition: rotate var(--ma-duration, 300ms) var(--ma-ease-out, ease-out); }
:where(.ma-radial__button svg), :where(.ma-radial__link svg) { inline-size: 1.25rem; block-size: 1.25rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
:where(.ma-radial__button:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
:where(.ma-radial:has(.ma-radial__menu:popover-open) .ma-radial__button) { rotate: 45deg; }
/* the ring: a box twice the radius, centred on the button where anchors are supported, in the middle of the screen
where they are not; the popover's own box is transparent and lets clicks through between the items */
:where(.ma-radial__menu) { inline-size: calc(var(--ma-radial-radius) * 2 + 3rem); block-size: calc(var(--ma-radial-radius) * 2 + 3rem); margin: 0; padding: 0; overflow: visible; border: 0; background: none; pointer-events: none; }
@supports (position-anchor: --a) {
:where(.ma-radial__menu) { position: absolute; inset: auto; top: anchor(center); left: anchor(center); translate: -50% -50%; }
}
:where(.ma-radial__items) { position: relative; inline-size: 100%; block-size: 100%; margin: 0; padding: 0; list-style: none; }
/* physical sides: the ring is drawn the same way whatever the writing direction */
:where(.ma-radial__item) { position: absolute; top: 50%; left: 50%; translate: calc(-50% + var(--x) * var(--ma-radial-radius)) calc(-50% + var(--y) * var(--ma-radial-radius)); }
:where(.ma-radial__link) { display: grid; place-items: center; inline-size: 2.75rem; block-size: 2.75rem; padding: 0; border: 0; border-radius: 999px; background: var(--card, var(--ma-panel)); color: var(--card-foreground, var(--ma-ink)); box-shadow: 0 0 0 1px var(--border, var(--ma-edge)), 0 6px 18px -8px rgb(0 0 0 / 0.35); font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; pointer-events: auto; transition: scale var(--ma-duration-fast, 180ms) var(--ma-ease-out, ease-out) calc(var(--i) * 30ms), opacity var(--ma-duration-fast, 180ms) var(--ma-ease-out, ease-out) calc(var(--i) * 30ms); }
:where(.ma-radial__link:hover) { background: var(--accent, var(--ma-edge)); }
:where(.ma-radial__link:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 2px; }
/* they grow out from the centre one after another as the menu opens */
@starting-style {
:where(.ma-radial__menu:popover-open .ma-radial__link) { scale: 0.2; opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-radial__button), :where(.ma-radial__link) { transition: none; }
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
items | { label; href?; action?; icon? }[] | none | Two to eight. A link, or a button that sends ma:radial with its action; icon is the d of one stroked path in a 24 by 24 box, else the label's first letter. |
label | string | none | The button's accessible name, and the ring's. |
radius | string | '5.5rem' | How far the items sit from the centre. |
from | number | 0 | Where the first item sits, in degrees from straight up, clockwise. |
spread | number | 360 | How much of the ring the items take, in degrees. |
The items appear at once and the button does not turn.
ClientRouterBound per element through the runtime; the popover itself needs no script.
Replaces: Radial menu (Animata) · Radial Menu (Animate UI). See the migration table.
A macOS-style dock: the tile under the pointer swells, its neighbours swell less, and the row pushes apart to make room.
Raw bytes of JavaScript, from a production build, measured again for every change that can move them.
A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.
Open the menu.
full · top · right: items in, reversed out
A burger that opens a full-screen menu (or a top sheet, or a side panel) whose items arrive one after another and leave in reverse.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown