Skip to content

Components / UI

Stagger Menu

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.

Live demo

studio

hello@studio.example · Jakarta · 2026

Open the menu.

full · top · right: items in, reversed out

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

Measured

JavaScript of its own

0 B

CSS 13.0 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 135 · Firefox 144 · Safari 26.2; needs command, starting-style, allow-discrete, dialog. 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.

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 StaggerMenu from '@moonarc/core/StaggerMenu';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Animated Icons and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/stagger-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 AnimatedIcons.astro.

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

Props

PropTypeDefaultDescription
idstringnoneDialog id; unique per page.
items{ href: string; label: string }[]noneThe 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.
currentstringnonehref of the current page (aria-current="page").
labelstring'Menu'Accessible name of the burger and the dialog.
colorstringnonePanel colour; default the ink token.
textColorstringnoneText colour; default the panel token.
polyfillbooleanfalseInline the invoker click handler (403 B raw) for browsers without command / commandfor (Safari < 26.2, Firefox < 144); without it the burger opens nothing there.

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.

Why it is built this way

Replaces: StaggerMenu (React Bits) · FullscreenMenu (Hover.dev) · Sheet (shadcn / Radix) · AnimatePresence menu (Framer Motion). See the migration table.

Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.

Chrome 52 · Firefox 97 · not Safariclick

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

drawer

Drag it down.

Follows the finger; on release, distance or velocity decides. Grab it mid-flight and it obeys.

A bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight.

newly · Chrome 135 · Firefox 144 · Safari 26.2clickpointer

A label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves.

every browserhover

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