Toggle Mobile Navigation Menu with Vanilla JavaScript

By · Updated

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 panel id and JS toggles aria-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 an aria-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.
Disclaimer: This tutorial is provided for educational and informational purposes only and does not constitute legal, financial, or professional advice. All content is offered “as-is” without warranties of any kind. Readers are solely responsible for implementation and must ensure compliance with applicable laws, accessibility standards, and platform terms. Always apply the information only within authorized, ethical, and legal contexts.

Spot an error or a better angle? Tell me and I’ll update the piece. I’ll credit you by name—or keep it anonymous if you prefer. Accuracy > ego.

Portrait of Mason Goulding

Mason Goulding · Founder, Maelstrom Web Services

Builder of fast, hand-coded static sites with SEO baked in. Stack: Eleventy · Vanilla JS · Netlify · Figma

With 10 years of writing expertise and currently pursuing advanced studies in computer science and mathematics, Mason blends human behavior insights with technical execution. His Master’s research at CSU–Sacramento examined how COVID-19 shaped social interactions in academic spaces — see his thesis on Relational Interactions in Digital Spaces During the COVID-19 Pandemic . He applies his unique background and skills to create successful builds for California SMBs.

Every build follows Google’s E-E-A-T standards: scalable, accessible, and future-proof.