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.
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
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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 moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/stagger-menu.jsonThe 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 — 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
| 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. |
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
- 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). See the migration table.
Related
Dialog
UIA 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.
Drawer
UIA 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.
Text Roll
TextA label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown