# Stagger Menu (Moonarc)

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. No script: a native modal dialog opened by the invoker command (focus trap, Escape, backdrop click and the focus returning to the burger are the browser's), and each item is a @starting-style transition delayed by its index on the stagger token. A link to a place on the same page carries a one-line onclick that closes the menu.

- Import: `import StaggerMenu from '@moonarc/core/StaggerMenu'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/stagger-menu.json`
- Tier A · category ui · trigger click
- Readout: `<StaggerMenu side="full">`
- Browser support: newly (Chrome 135 · Firefox 144 · Safari 26.2); elsewhere: without invoker commands the burger needs polyfill (an inline handler of 403 B raw); without @starting-style the menu opens without the stagger; without closedby (Safari) a backdrop click does nothing, and Escape and the close button still close it
- Measured cost: 0 B JS (CSS 13.0 kB raw)
- Page: https://moonarc.dev/components/stagger-menu/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; unique per page. |
| `items` | `{ href: string; label: string }[]` | none | The links, numbered in order. A link to a place on the current page (#…) closes the menu when followed, through an onclick of 30 B raw rendered on that link only. |
| `side` | `'full' | 'top' | 'right'` | `'full'` | full covers the viewport with big type; top is a sheet from the top edge; right a panel from the right. |
| `current` | `string` | none | href of the current page (aria-current="page"). |
| `label` | `string` | `'Menu'` | Accessible name of the burger and the dialog. |
| `color` | `string` | none | Panel colour; default the ink token. |
| `textColor` | `string` | none | Text colour; default the panel token. |
| `polyfill` | `boolean` | `false` | Inline the invoker click handler (403 B raw) for browsers without command / commandfor (Safari < 26.2, Firefox < 144); without it the burger opens nothing there. |

## Usage

```astro
<StaggerMenu
  id="menu"
  items={[
    { href: '/', label: 'Home' },
    { href: '/work/', label: 'Work' },
    { href: '/studio/', label: 'Studio' },
    { href: '/contact/', label: 'Contact' },
  ]}
  current="/work/"
>
  <p>hello@studio.example · Jakarta</p>
</StaggerMenu>
```

## Reduced motion

The panel and every item cross-fade together in 150 ms; no travel, no stagger.

## With ClientRouter

Native; nothing to rebind. Following a link to another page closes the menu with the page; mark the new page with current. A link to a place on the same page closes it through its onclick.

## Craft

- A modal <dialog> is the cheapest focus trap there is: Escape, the backdrop click (closedby="any", Chrome and Firefox) and the focus returning to the burger cost nothing and cannot drift from the platform.
- A link to #section on the same page never unloads it, so the modal would stay over the section it scrolled to. Those links, found at build by comparing the href with the page's own URL, carry onclick="this.closest('dialog').close()"; the menu folds away while the page scrolls.
- Each item's delay is index × the stagger token on the way in and (n − 1 − index) × stagger on the way out, so the menu folds back the way it unfolded; the panel's own fade waits n × stagger so it never leaves before its last item.
- display and overlay transition with allow-discrete for duration + n × stagger: the top layer holds the panel exactly as long as the exit takes.
- The X is an AnimatedIcons menu in its "on" state whose paths start in the burger shape through @starting-style, so opening reads as the burger folding into the X even though the two are different elements.
- Big type is the whole design of a fullscreen menu: clamp(2rem, 7vw, 4.5rem), tight tracking, an index in mono at half opacity. The top and right sides step it down.

## Replaces

- StaggerMenu (React Bits)
- FullscreenMenu (Hover.dev)
- Sheet (shadcn / Radix)
- AnimatePresence menu (Framer Motion)

## Source

```astro
---
/**
 * StaggerMenu — a burger that opens a full-screen (or top, or side) menu
 * whose items arrive one after another, zero JS.
 *
 * The menu is a native modal <dialog> opened by the invoker command, so the
 * focus trap, Escape, the backdrop click and the focus returning to the
 * burger are the browser's. Each item transitions in from the enter travel
 * with a delay of index × stagger through @starting-style; on close the
 * delays reverse and display/overlay wait through allow-discrete for the
 * last item to leave. The burger and the close button are AnimatedIcons:
 * the X starts as three lines and folds as the menu opens. A link to a place
 * on the same page carries a one-line onclick that closes the menu, or the
 * page would scroll to it behind the open modal.
 */
import type { HTMLAttributes } from 'astro/types';
import AnimatedIcons from './AnimatedIcons.astro';

interface Item {
  href: string;
  label: string;
}

interface Props extends HTMLAttributes<'div'> {
  /** Dialog id; unique per page. */
  id: string;
  items: Item[];
  /** full: covers the viewport · top: a sheet from the top · right: a panel from the right. */
  side?: 'full' | 'top' | 'right';
  /** href of the current page (aria-current). */
  current?: string;
  /** Accessible name of the trigger and the dialog. */
  label?: string;
  /** Menu colours; default ink on panel text. */
  color?: string;
  textColor?: string;
  /** Add the inline click handler for browsers without invoker commands. */
  polyfill?: boolean;
}

const { id, items, side = 'full', current, label = 'Menu', color, textColor, polyfill = false, class: className, style, ...rest } = Astro.props;
const vars = [`--ma-smenu-n:${items.length}`, color && `--ma-smenu-bg:${color}`, textColor && `--ma-smenu-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const triggerAttrs = { command: 'show-modal', commandfor: id };
const pad = (n: number) => String(n).padStart(2, '0');
// a link to a place on this page (a hash on the current path) never unloads it, so nothing would close the modal. An href
// URL() cannot parse is rendered as given, as before, and is not one of those.
const here = (href: string) => {
  try {
    const u = new URL(href, Astro.url);
    return !!u.hash && u.pathname === Astro.url.pathname && u.search === Astro.url.search;
  } catch {
    return false;
  }
};
---

<div class:list={['ma-smenu', className]} data-side={side} style={vars} {...rest}>
  <button type="button" class="ma-smenu__trigger" aria-haspopup="dialog" aria-label={label} {...triggerAttrs}>
    <AnimatedIcons icon="menu" size="1.25em" />
  </button>
  <dialog id={id} class="ma-smenu__dialog" closedby="any" aria-label={label}>
    <form method="dialog" class="ma-smenu__close">
      <button type="submit" aria-label="Close menu">
        <AnimatedIcons icon="menu" state="on" size="1.25em" />
      </button>
    </form>
    <nav class="ma-smenu__nav" aria-label={label}>
      <ul class="ma-smenu__list">
        {
          items.map((it, i) => (
            <li class="ma-smenu__item" style={`--ma-i:${i}`}>
              <a href={it.href} class="ma-smenu__link" aria-current={it.href === current ? 'page' : undefined} onclick={here(it.href) ? "this.closest('dialog').close()" : undefined}>
                <span class="ma-smenu__index" aria-hidden="true">{pad(i + 1)}</span>
                <span class="ma-smenu__label">{it.label}</span>
              </a>
            </li>
          ))
        }
      </ul>
    </nav>
    {Astro.slots.has('default') && <div class="ma-smenu__foot"><slot /></div>}
  </dialog>
</div>
{polyfill && <script is:inline>window.__maCmd||(window.__maCmd=1,document.addEventListener('click',function(e){if('command'in HTMLButtonElement.prototype||e.defaultPrevented)return;var b=e.target.closest('[commandfor]');if(!b)return;var d=document.getElementById(b.getAttribute('commandfor'));if(!d)return;var c=b.getAttribute('command');c==='show-modal'?d.showModal():c==='close'?d.close():c==='toggle-popover'?d.togglePopover():0}))</script>}

<style is:global>
  @layer components {
    :where(.ma-smenu) {
      /* the exit has to wait for the last item: the panel's display transition is duration + n × stagger */
      --ma-smenu-total: calc(var(--ma-duration) + var(--ma-smenu-n, 4) * var(--ma-stagger));
      display: inline-block;
    }
    :where(.ma-smenu__trigger),
    :where(.ma-smenu__close button) {
      display: grid;
      place-items: center;
      width: 2.5rem;
      height: 2.5rem;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: var(--ma-panel);
      color: inherit;
      cursor: pointer;
      transition: background-color var(--ma-duration-fast) var(--ma-ease-out), scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-smenu__trigger:hover),
    :where(.ma-smenu__close button:hover) {
      scale: 1.06;
    }
    :where(.ma-smenu__trigger:focus-visible),
    :where(.ma-smenu__close button:focus-visible),
    :where(.ma-smenu__link:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 3px;
    }
    /* the panel */
    :where(.ma-smenu__dialog) {
      position: fixed;
      inset: 0;
      margin: 0;
      width: 100%;
      height: 100%;
      max-width: none;
      max-height: none;
      padding: clamp(1.5rem, 6vw, 4rem);
      border: 0;
      background: var(--ma-smenu-bg, var(--ma-ink));
      color: var(--ma-smenu-fg, var(--ma-panel));
      overflow: auto;
      opacity: 0;
      translate: 0 var(--ma-travel-enter, 24px);
      transition:
        opacity var(--ma-duration) var(--ma-ease-in) calc(var(--ma-smenu-n, 4) * var(--ma-stagger)),
        translate var(--ma-duration) var(--ma-ease-in) calc(var(--ma-smenu-n, 4) * var(--ma-stagger)),
        display var(--ma-smenu-total) allow-discrete,
        overlay var(--ma-smenu-total) allow-discrete;
    }
    :where(.ma-smenu__dialog[open]) {
      opacity: 1;
      translate: 0 0;
      transition-delay: 0s;
      transition-timing-function: var(--ma-ease);
    }
    @starting-style {
      :where(.ma-smenu__dialog[open]) {
        opacity: 0;
        translate: 0 var(--ma-travel-enter, 24px);
      }
    }
    :where(.ma-smenu[data-side='top'] .ma-smenu__dialog) {
      height: auto;
      max-height: 90dvh;
      inset: 0 0 auto 0;
      border-radius: 0 0 1.5rem 1.5rem;
      translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
    }
    :where(.ma-smenu[data-side='right'] .ma-smenu__dialog) {
      width: min(26rem, 100%);
      inset: 0 0 0 auto;
      translate: var(--ma-travel-enter, 24px) 0;
    }
    :where(.ma-smenu[data-side='top'] .ma-smenu__dialog[open]),
    :where(.ma-smenu[data-side='right'] .ma-smenu__dialog[open]) {
      translate: 0 0;
    }
    @starting-style {
      :where(.ma-smenu[data-side='top'] .ma-smenu__dialog[open]) {
        translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
      }
      :where(.ma-smenu[data-side='right'] .ma-smenu__dialog[open]) {
        translate: var(--ma-travel-enter, 24px) 0;
      }
    }
    :where(.ma-smenu__dialog)::backdrop {
      background: var(--ma-scrim);
      opacity: 0;
      transition:
        opacity var(--ma-duration) var(--ma-ease-out),
        display var(--ma-smenu-total) allow-discrete,
        overlay var(--ma-smenu-total) allow-discrete;
    }
    :where(.ma-smenu__dialog[open])::backdrop {
      opacity: 1;
    }
    @starting-style {
      :where(.ma-smenu__dialog[open])::backdrop {
        opacity: 0;
      }
    }
    :where(.ma-smenu__close) {
      position: absolute;
      inset-block-start: 1.25rem;
      inset-inline-end: 1.25rem;
    }
    /* the X starts as the burger and folds as the panel arrives */
    @starting-style {
      :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__a),
      :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__c) {
        translate: 0 0;
        rotate: 0deg;
      }
      :where(.ma-smenu__dialog[open] .ma-smenu__close .ma-icon__b) {
        scale: 1 1;
        opacity: 1;
      }
    }
    :where(.ma-smenu__nav) {
      display: grid;
      align-content: center;
      min-height: 100%;
    }
    :where(.ma-smenu[data-side='top'] .ma-smenu__nav),
    :where(.ma-smenu[data-side='right'] .ma-smenu__nav) {
      min-height: 0;
      padding-top: 2.5rem;
    }
    :where(.ma-smenu__list) {
      display: grid;
      gap: 0.25em;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    /* the items: in order on open, reversed on close, each on the stagger token */
    :where(.ma-smenu__item) {
      opacity: 0;
      translate: 0 var(--ma-travel-enter, 24px);
      transition:
        opacity var(--ma-duration) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease);
      transition-delay: calc((var(--ma-smenu-n, 4) - 1 - var(--ma-i, 0)) * var(--ma-stagger));
    }
    :where(.ma-smenu__dialog[open] .ma-smenu__item) {
      opacity: 1;
      translate: 0 0;
      transition-delay: calc(var(--ma-i, 0) * var(--ma-stagger) + var(--ma-duration-fast));
    }
    @starting-style {
      :where(.ma-smenu__dialog[open] .ma-smenu__item) {
        opacity: 0;
        translate: 0 var(--ma-travel-enter, 24px);
      }
    }
    :where(.ma-smenu__link) {
      display: flex;
      align-items: baseline;
      gap: 0.75em;
      padding: 0.15em 0;
      color: inherit;
      text-decoration: none;
      font-size: clamp(2rem, 7vw, 4.5rem);
      font-weight: 700;
      letter-spacing: -0.03em;
      line-height: 1.05;
      opacity: 0.85;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-smenu[data-side='top'] .ma-smenu__link),
    :where(.ma-smenu[data-side='right'] .ma-smenu__link) {
      font-size: clamp(1.5rem, 4vw, 2.25rem);
    }
    :where(.ma-smenu__link:hover),
    :where(.ma-smenu__link[aria-current='page']) {
      opacity: 1;
      translate: 0.25em 0;
    }
    :where(.ma-smenu__index) {
      font-family: ui-monospace, monospace;
      font-size: 0.75rem;
      font-weight: 400;
      letter-spacing: 0.1em;
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-smenu__foot) {
      margin-top: 2rem;
      font-size: 0.875rem;
      opacity: 0.7;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-smenu__dialog),
      :where(.ma-smenu__dialog[open]),
      :where(.ma-smenu__item),
      :where(.ma-smenu__dialog[open] .ma-smenu__item) {
        translate: 0 0;
        transition-duration: var(--ma-duration-fast);
        transition-delay: 0s;
        transition-timing-function: linear;
      }
      :where(.ma-smenu) {
        --ma-smenu-total: var(--ma-duration-fast);
      }
      :where(.ma-smenu__link),
      :where(.ma-smenu__trigger),
      :where(.ma-smenu__close button) {
        transition: none;
      }
    }
  }
</style>

```
