Fullscreen menu overlay
A menu that takes the entire viewport, with oversized links revealed in sequence, replacing a conventional dropdown.
Grade: ARuntimes: cssStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
When there are fewer than eight destinations, a full-screen menu lets navigation be set at the same scale as the rest of the page instead of shrinking to fit a bar. The cost is that it is a modal, and it has to be built like one.
Implementation
`allow-discrete` plus `@starting-style` lets the native dialog fade in and out with no JavaScript animation bookkeeping.
Dependencies: none
<button class="menu__trigger" aria-expanded="false" aria-controls="menu">Menu</button>
<dialog class="menu" id="menu">
<nav aria-label="Primary">
<ul class="menu__list">
<li style="--i: 0"><a href="/work">Work</a></li>
<li style="--i: 1"><a href="/studio">Studio</a></li>
<li style="--i: 2"><a href="/contact">Contact</a></li>
</ul>
</nav>
<button class="menu__close" autofocus>Close</button>
</dialog>
<style>
.menu {
width: 100vw;
max-width: none;
height: 100svh;
max-height: none;
border: 0;
padding: var(--space-xl) var(--gutter);
color: var(--ink-inverse);
background: var(--bg-inverse);
opacity: 0;
transition: opacity var(--dur-micro, 0.4s) allow-discrete;
}
.menu[open] { opacity: 1; }
@starting-style {
.menu[open] { opacity: 0; }
}
.menu__list { list-style: none; margin: 0; padding: 0; }
.menu__list a {
display: block;
font-size: var(--fs-h1);
line-height: 1.05;
letter-spacing: -0.02em;
text-decoration: none;
color: inherit;
opacity: 0;
transform: translateY(0.4em);
transition:
opacity 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--i) * 70ms),
transform 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--i) * 70ms);
}
.menu[open] .menu__list a { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.menu, .menu__list a { transition: none; }
}
</style>
<script type="module">
const trigger = document.querySelector(".menu__trigger");
const dialog = document.getElementById("menu");
const close = dialog?.querySelector(".menu__close");
trigger?.addEventListener("click", () => {
dialog.showModal();
trigger.setAttribute("aria-expanded", "true");
document.body.style.overflow = "hidden";
});
close?.addEventListener("click", () => dialog.close());
// Fires for the close button, Escape, and backdrop dismissal alike.
dialog?.addEventListener("close", () => {
trigger.setAttribute("aria-expanded", "false");
document.body.style.overflow = "";
trigger.focus();
});
</script>Use it when
- Sites with fewer than eight top-level destinations.
- Mobile navigation at any site size.
Avoid it when
- Deep hierarchies that need nesting.
- Applications where navigation happens constantly — a modal on every move is exhausting.
Accessibility
- Use native `<dialog>` with `showModal()`. It gives you the focus trap, the inert background, and Escape-to-close for free — hand-rolled versions get at least one of those wrong.
- Return focus to the trigger on close and keep `aria-expanded` in sync.
- Lock background scroll while open, and restore the exact scroll position on close.
- Links must be reachable in DOM order; do not reorder them visually in a way that contradicts tab order.
Performance
- Animate `opacity` and `transform` on the links, not on the dialog's `display`.
- Keep the dialog in the DOM rather than mounting and unmounting it — the first open should not pay a render cost.
Knobs
- stagger
- Delay between link entrances. Default: 70ms
- backdrop
- Solid inverse fill or a translucent scrim. Default: solid
Composition
Conflicts with
None.
Instruction for Claude Code
Build a fullscreen menu using a native <dialog> opened with showModal() — do not hand-roll a focus trap. Size it 100vw by 100svh with no border, fade it with transition-behavior allow-discrete plus @starting-style, and stagger the oversized links with a --i custom property times 70ms. Keep aria-expanded on the trigger in sync, lock body scroll while open, and in a single close event handler restore scroll, reset aria-expanded, and return focus to the trigger so Escape and backdrop dismissal are covered too. Disable transitions under prefers-reduced-motion.