Skip to content

Components / UI

Radial Menu

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 demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · newly available

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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/radial-menu.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

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

Props

PropTypeDefaultDescription
items{ label; href?; action?; icon? }[]noneTwo 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.
labelstringnoneThe button's accessible name, and the ring's.
radiusstring'5.5rem'How far the items sit from the centre.
fromnumber0Where the first item sits, in degrees from straight up, clockwise.
spreadnumber360How much of the ring the items take, in degrees.

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.

Why it is built this way

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.

every browserhover

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.

newly · Chrome 125 · Firefox 147 · Safari 26click
studio

hello@studio.example · Jakarta · 2026

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.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown