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>witharia-expandedis 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-expandedtells screen readers whether the menu is open, and thedata-openattribute 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-loadre-runs setup after navigation when<ClientRouter />is used; thedata-readyguard prevents double binding.
Variations
- Close on outside click: listen for
clickondocumentand close if the target is outsidenav. - Lock page scroll when open: toggle
overflow: hiddenon<html>along withdata-open. - Slide-in drawer: replace
display: none/flexwith atransform: translateX(100%)transition, and addinertto 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.