komal.nakrani
Browse Themes
ThemesCategoriesDocsBlogTips & Tricks
Components & Islands••By Komal Nakrani

Accessible Astro Mobile Menu Component

A copy-paste Astro mobile menu: a button with aria-expanded, a nav that hides on small screens, Escape to close, and under 1 KB of JavaScript. No framework needed.

#Astro#Components#Navigation#Accessibility

This Astro mobile menu is a single .astro component: a toggle button with aria-expanded and aria-controls, a navigation list that collapses below a breakpoint, and a short script that opens and closes it, closes on Escape, and returns focus to the button. It ships no framework and works with or without view transitions.

Key Takeaways

  • A real <button> with aria-expanded is what makes a menu toggle accessible.
  • CSS decides when the toggle appears; the script only flips state.
  • Escape closes the menu and focus returns to the toggle.
  • No framework island is needed for this; a processed <script> is enough.

The component

---
// src/components/SiteNav.astro
interface Link { href: string; label: string }
interface Props { links: Link[] }

const { links } = Astro.props;
const path = Astro.url.pathname;
const isCurrent = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
---
<nav class="site-nav" aria-label="Main">
  <button
    class="nav-toggle"
    type="button"
    aria-expanded="false"
    aria-controls="site-nav-list"
  >
    <span class="sr-only">Menu</span>
    <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
      <path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
    </svg>
  </button>

  <ul id="site-nav-list" class="nav-list">
    {links.map(({ href, label }) => (
      <li>
        <a href={href} aria-current={isCurrent(href) ? 'page' : undefined}>
          {label}
        </a>
      </li>
    ))}
  </ul>
</nav>

<style>
  .nav-list { display: flex; gap: 1.5rem; list-style: none; margin: 0; padding: 0; }
  .nav-list a { text-decoration: none; color: inherit; }
  .nav-list a[aria-current='page'] { text-decoration: underline; text-underline-offset: 6px; }
  .nav-toggle { display: none; background: none; border: 0; padding: 0.5rem; color: inherit; cursor: pointer; }

  @media (max-width: 768px) {
    .nav-toggle { display: inline-flex; }
    .nav-list {
      display: none;
      position: absolute;
      inset: 100% 0 auto 0;
      flex-direction: column;
      gap: 0;
      padding: 0.5rem 1rem 1rem;
      background: var(--color-bg, #fff);
      border-bottom: 1px solid var(--color-border, #e5e4df);
    }
    .nav-list a { display: block; padding: 0.75rem 0; }
    .site-nav[data-open] .nav-list { display: flex; }
  }

  .site-nav { position: relative; }
  .sr-only {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
  }
</style>

<script>
  function setupNav() {
    document.querySelectorAll<HTMLElement>('.site-nav').forEach((nav) => {
      const toggle = nav.querySelector<HTMLButtonElement>('.nav-toggle');
      if (!toggle || toggle.dataset.ready) return;
      toggle.dataset.ready = 'true';

      const setOpen = (open: boolean) => {
        toggle.setAttribute('aria-expanded', String(open));
        nav.toggleAttribute('data-open', open);
      };

      toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true'));

      nav.addEventListener('keydown', (e) => {
        if (e.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
          setOpen(false);
          toggle.focus();
        }
      });

      nav.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => setOpen(false)));
    });
  }

  setupNav();
  document.addEventListener('astro:page-load', setupNav);
</script>

Using it

---
import SiteNav from '../components/SiteNav.astro';
const links = [
  { href: '/', label: 'Home' },
  { href: '/work/', label: 'Work' },
  { href: '/blog/', label: 'Blog' },
  { href: '/contact/', label: 'Contact' },
];
---
<header class="site-header">
  <a href="/" class="logo">Northwind</a>
  <SiteNav links={links} />
</header>

Keep the link list in a data file (src/data/site.ts) so the header and footer share it.

How it works

  • The button is the source of truth. aria-expanded tells screen readers whether the menu is open, and the data-open attribute on <nav> drives the CSS.
  • CSS controls the breakpoint. Above 768px the toggle is hidden and the list is always visible, so the script is irrelevant on desktop.
  • aria-current="page" marks the current link for assistive tech and gives you a styling hook.
  • astro:page-load re-runs setup after navigation when <ClientRouter /> is used; the data-ready guard prevents double binding.

Variations

  • Close on outside click: listen for click on document and close if the target is outside nav.
  • Lock page scroll when open: toggle overflow: hidden on <html> along with data-open.
  • Slide-in drawer: replace display: none/flex with a transform: translateX(100%) transition, and add inert to the list when closed so hidden links can’t be tabbed to.

Why not a React island?

A menu only flips one attribute. A framework island would ship a runtime for that. If you already use React islands on every page, a React menu costs little extra; otherwise this script is smaller and works before any framework loads. The trade-off is covered in island or script.

For more component patterns, see the Astro components guide. If you’d rather start with navigation already built, every Komal theme includes a responsive header. The WAI-ARIA disclosure pattern describes the accessibility model used here.

FAQ

Does this mobile menu work without JavaScript?

On desktop, yes: the links are always visible. On mobile the list stays hidden without JavaScript; for a no-JS fallback, use a <details> element instead of the button.

How do I change the breakpoint?

Change 768px in the media query. The script doesn’t depend on it.