Toggle Mobile Navigation Menu with Vanilla JavaScript
A tiny, accessible mobile menu pattern: ARIA-correct button, focus containment, scroll lock, ESC/backdrop to close, and motion-safe transitions—no frameworks required.
Why a11y-first mobile nav matters
- Clarity: Screen readers announce the menu’s state via
aria-expanded
and labelling. - Control: Keyboard and switch users can open, navigate links, and close predictably.
- Calm: Transitions respect
prefers-reduced-motion
; no surprise motion. - Focus: Focus stays inside the menu while open and returns to the toggle on close.
Semantic Markup Scaffold
Drop this into your header. The button controls a <nav>
off-canvas panel.
<header class="sticky top-0 z-50 bg-white/90 dark:bg-gray-900/80 backdrop-blur border-b">
<div class="mx-auto max-w-6xl flex items-center justify-between px-4 py-3">
<a href="/" class="font-bold text-lg">Brand</a>
<!-- Mobile toggle -->
<button id="nav-toggle"
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 border md:hidden"
aria-expanded="false"
aria-controls="mobile-nav"
aria-label="Open menu">
<span aria-hidden="true">☰</span> Menu
</button>
<!-- Desktop nav -->
<nav class="hidden md:flex gap-6" aria-label="Primary">
<a href="/services/">Services</a>
<a href="/work/">Work</a>
<a href="/blog/">Blog</a>
<a href="/contact/" class="font-semibold">Contact</a>
</nav>
</div>
<!-- Mobile off-canvas (initially hidden) -->
<div id="mobile-nav-wrapper" class="md:hidden">
<div id="mobile-nav-backdrop" class="fixed inset-0 bg-black/40 opacity-0 pointer-events-none transition-opacity"></div>
<nav id="mobile-nav"
class="fixed left-0 top-0 h-svh w-80 max-w-[86vw] -translate-x-full
bg-white dark:bg-gray-900 shadow-xl outline-none
transition-transform will-change-transform"
tabindex="-1" aria-label="Mobile">
<div class="flex items-center justify-between px-4 py-3 border-b">
<span class="font-semibold">Menu</span>
<button class="rounded-lg px-3 py-2 border" data-close aria-label="Close menu">✕</button>
</div>
<ul class="p-4 space-y-2">
<li><a class="block rounded px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800" href="/services/">Services</a></li>
<li><a class="block rounded px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800" href="/work/">Work</a></li>
<li><a class="block rounded px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800" href="/blog/">Blog</a></li>
<li><a class="block rounded px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 font-semibold" href="/contact/">Contact</a></li>
</ul>
</nav>
</div>
</header>
The wrapper/backdrop lives outside the off-canvas panel to detect clicks. The panel itself takes focus when opened.
CSS: Transitions & Motion Safety
/* Open states toggled by JS via .is-open on #mobile-nav-wrapper */
#mobile-nav-wrapper.is-open #mobile-nav { transform: translateX(0); }
#mobile-nav-wrapper.is-open #mobile-nav-backdrop {
opacity: 1; pointer-events: auto;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
#mobile-nav { transition: none !important; }
#mobile-nav-backdrop { transition: none !important; }
}
Controller: Toggle, Focus Trap, Scroll Lock
Single controller handles open/close, focus containment, ESC/backdrop, and restores focus to the toggle.
<script>
(() => {
const toggle = document.getElementById('nav-toggle');
const wrapper = document.getElementById('mobile-nav-wrapper');
const panel = document.getElementById('mobile-nav');
const backdrop = document.getElementById('mobile-nav-backdrop');
if (!toggle || !wrapper || !panel || !backdrop) return;
let lastFocused = null;
function lockScroll(lock) {
const html = document.documentElement;
if (lock) {
const scrollY = window.scrollY;
html.style.position = 'fixed';
html.style.top = `-${scrollY}px`;
html.dataset.lockedScrollY = String(scrollY);
html.style.width = '100%';
} else {
const y = parseInt(document.documentElement.dataset.lockedScrollY || '0', 10);
html.style.position = ''; html.style.top = ''; html.style.width = '';
window.scrollTo(0, y);
}
}
function getFocusables(root) {
return [...root.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)].filter(el => el.offsetParent !== null || el === root);
}
function openMenu() {
if (wrapper.classList.contains('is-open')) return;
lastFocused = document.activeElement;
wrapper.classList.add('is-open');
toggle.setAttribute('aria-expanded', 'true');
toggle.setAttribute('aria-label', 'Close menu');
lockScroll(true);
panel.focus();
// Trap focus
const f = getFocusables(panel);
panel.addEventListener('keydown', trapTab);
function trapTab(e) {
if (e.key !== 'Tab' || f.length === 0) return;
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
panel.dataset.trap = '1';
// Close on ESC / backdrop / data-close
document.addEventListener('keydown', onEsc);
backdrop.addEventListener('click', closeMenu);
panel.addEventListener('click', onMaybeClose);
function onMaybeClose(e){ if (e.target.closest('[data-close], a[href]')) closeMenu(); }
// Store handlers for cleanup
panel._cleanup = () => {
panel.removeEventListener('keydown', trapTab);
backdrop.removeEventListener('click', closeMenu);
panel.removeEventListener('click', onMaybeClose);
document.removeEventListener('keydown', onEsc);
delete panel._cleanup;
};
function onEsc(e){ if (e.key === 'Escape') closeMenu(); }
}
function closeMenu() {
if (!wrapper.classList.contains('is-open')) return;
wrapper.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-label', 'Open menu');
lockScroll(false);
panel._cleanup?.();
lastFocused?.focus?.();
}
// Toggle click
toggle.addEventListener('click', () => {
wrapper.classList.contains('is-open') ? closeMenu() : openMenu();
});
// Close on breakpoint up (if resizing while open)
const mql = matchMedia('(min-width: 768px)');
mql.addEventListener?.('change', (e) => { if (e.matches) closeMenu(); });
})();
</script>
We lock scrolling by fixing the root and restoring the exact scroll position on close—no jump. Links inside the menu also close it for quick navigation.
Optional Enhancements
- Reduce Motion: Additional class that swaps slide-in for a simple fade when
prefers-reduced-motion
is on. - ARIA current: Add
aria-current="page"
to the active link for better SR context. - Focus ring: Ensure visible focus styles for menu items (don’t remove outlines).
- Analytics: Track open/close and popular links to refine IA.
Common Pitfalls (and Fixes)
- Body scroll still moves: Lock the root element (or body) and restore position after close.
- Screen reader says “button collapsed” but nothing happens: Ensure
aria-controls
targets the panelid
and JS togglesaria-expanded
. - Focus escapes: Recompute focusables after dynamic content; keep the trap in the panel container.
- Menu sticks open on resize: Listen for breakpoint changes and force-close when crossing to desktop.
- Motion sickness reports: Respect
prefers-reduced-motion
and shorten transitions.
QA Checklist
- ✅ Toggle updates
aria-expanded
and label text (“Open/Close menu”). - ✅ Focus moves into panel on open, returns to toggle on close; Tab is trapped inside.
- ✅ ESC and backdrop click close the menu.
- ✅ Scroll is locked without layout shift; restored on close.
- ✅ Menu auto-closes when resizing to desktop.
- ✅ Transitions disabled under
prefers-reduced-motion
.
FAQs
Why not use <dialog>
for mobile nav?
<dialog>
is great for modals—navs are persistent UI. A labelled <nav>
with proper state is more semantically fitting.
Do I need a focus trap?
Yes. Off-canvas menus behave like modals while open; confining focus prevents users from tabbing into the page underneath.
Will this hurt Core Web Vitals?
No—panel is present in the DOM and transforms animate on the compositor. Keep transitions short and avoid layout-affecting properties.
Key Takeaways
- Use a labelled button with
aria-expanded
and anaria-controls
link to the panel. - Trap focus inside the panel; close via ESC, backdrop, and link clicks.
- Lock page scroll cleanly and restore position on close.
- Respect motion preferences; keep animations subtle and compositor-only.