# Radial Menu (Moonarc)

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.

- Import: `import RadialMenu from '@moonarc/core/RadialMenu'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/radial-menu.json`
- Tier B · category ui · trigger click
- Readout: `<RadialMenu label="Share" items={[{ label, href, icon }]} />`
- Browser support: newly (Chrome 125 · Firefox 147 · Safari 26); elsewhere: without anchor positioning the ring opens in the middle of the screen; without @starting-style the items appear at once
- Measured cost: 743 B raw JS · 448 B gzip · + runtime (with dependencies 2.4 kB raw; CSS 6.3 kB raw)
- Page: https://moonarc.dev/components/radial-menu/

## Props

| 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. |

## Usage

```astro
<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' },
  ]}
/>
```

## Reduced motion

The items appear at once and the button does not turn.

## With ClientRouter

Bound per element through the runtime; the popover itself needs no script.

## Craft

- Each item's place on the ring is a sine and a cosine written at build as two custom properties; the radius is one length, so the ring scales with it.
- The popover's box is the ring's square, transparent and pointer-events: none, so a click between two items reaches the page under it.
- The items grow from a fifth of their size through @starting-style, each 30 ms after the one before.

## Replaces

- Radial menu (Animata)
- Radial Menu (Animate UI)

## Source

```astro
---
/**
 * 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>

```
