Rejouice — loading0

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

html-css-js
<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

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.