research/building-an-accessible-mobile-menu-without-a-framework

Building an Accessible Mobile Menu Without a Framework

Engineering Note · October 28, 2026 · 7 minute read · Accessibility · HTML · JavaScript

An accessible mobile menu is a disclosure widget: a real button that shows and hides a navigation panel, announces its state with aria-expanded, works from the keyboard, and leaves every link reachable if the script never runs. We built one for this site with no framework. It is a button, a panel, and a short script, and the parts that matter are small enough to test line by line.

The demo below follows the contract we describe here. It is a minimal page written for this note, not our production markup. Versions: Playwright (playwright-core) 1.63.0, Chromium 141, axe-core 4.13.0, Node 22.

What markup does an accessible mobile menu need?

The toggle is a real button. It carries aria-expanded, which says whether the menu is open, and aria-controls, which points at the id of the nav panel. Its aria-label switches between “Menu” and “Close menu”, so the name always describes what pressing it will do.

<button class="menu-toggle" aria-expanded="false"
        aria-controls="nav-panel" aria-label="Menu">
  <!-- icon -->
</button>
<nav id="nav-panel" class="nav-panel" aria-label="Main"> ...links... </nav>

The toggle is a 44px square. That is a comfortable tap target on a phone, and it costs nothing to get right at the start.

How do you keep the menu usable without JavaScript?

The menu only makes sense if a script is running to open it. If it isn’t, hiding the links would leave visitors with no navigation. So a tiny inline script in the document head adds a js class to the html element:

<script>document.documentElement.classList.add('js');</script>

CSS can then apply the collapsed menu only when that class exists. The panel is display: none unless the nav has an is-open class, and that rule sits under .js. Without JavaScript the class never appears, and the header falls back to a trimmed row of links. It is not as rich as the full navigation, but every important page is reachable.

.menu-toggle { display: none; width: 44px; height: 44px; }
.js .menu-toggle { display: inline-block; }
@media (max-width: 1020px) {
  .js .nav-panel { display: none; position: fixed; inset: 60px 0 0 0; background: #fff; }
  .js .nav-panel.is-open { display: block; }
}
@media (min-width: 1021px) { .menu-toggle { display: none !important; } }

How should the open and close behavior work?

The script is short. One function sets the open state and keeps everything consistent:

const button = document.querySelector('.menu-toggle');
const nav = document.getElementById('nav-panel');

function setOpen(open) {
  nav.classList.toggle('is-open', open);
  button.setAttribute('aria-expanded', String(open));
  button.setAttribute('aria-label', open ? 'Close menu' : 'Menu');
  document.documentElement.style.overflow = open ? 'hidden' : '';
}
button.addEventListener('click', () => setOpen(button.getAttribute('aria-expanded') !== 'true'));
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && nav.classList.contains('is-open')) { setOpen(false); button.focus(); }
});
nav.addEventListener('click', (e) => { if (e.target.closest('a')) setOpen(false); });
document.addEventListener('click', (e) => {
  if (nav.classList.contains('is-open') && !nav.contains(e.target) && !button.contains(e.target)) setOpen(false);
});
window.matchMedia('(min-width: 1021px)').addEventListener('change', (e) => { if (e.matches) setOpen(false); });

Every way of opening or closing goes through that one function, so the class, the ARIA state, the label, and the scroll lock cannot disagree. The ways to close are:

  • Escape closes the menu and returns focus to the button. Without the focus return, a keyboard user is dropped somewhere arbitrary in the page.
  • Clicking a link closes it, which matters for in-page anchors where the page does not reload.
  • Tapping outside the panel closes it.
  • Growing the window to desktop width closes it. A matchMedia listener on (min-width: 1021px) handles the case where someone rotates a tablet or resizes a window with the menu open.

While the menu is open, overflow: hidden on the html element stops the page from scrolling behind the panel.

closed --click toggle--> open
open   --click toggle / Escape (focus -> button) / link click /
         outside click / viewport >= 1021px--> closed

What does the test run look like?

We drive the demo with Playwright at a 390px-wide viewport. Each test gets a fresh browser context, so state never leaks between them:

async function t(name, fn, ctxOpts = { viewport: { width: 390, height: 800 } }) {
  const ctx = await browser.newContext(ctxOpts);
  const page = await ctx.newPage();
  await page.goto(url);
  try { await fn(page); results.push(['PASS', name]); }
  catch (e) { results.push(['FAIL', name + ' -> ' + e.message.split('\n')[0]]); }
  await ctx.close();
}

await t('Escape closes and returns focus to the button', async (p) => {
  await p.locator('.menu-toggle').click();
  await p.locator('#nav-panel a').first().focus();
  await p.keyboard.press('Escape');
  assert.equal(await p.locator('.menu-toggle').getAttribute('aria-expanded'), 'false');
  assert.equal(await p.evaluate(() => document.activeElement.className), 'menu-toggle');
});

The other tests follow the same pattern: assert the closed start state, open and check aria-expanded, the label, the link count and the scroll lock, close by outside click, link click, and resize, check the 44px size, and run axe on the open state. With JavaScript disabled in the context, the last test checks the fallback. The real output:

PASS starts closed, links hidden
PASS click opens: aria-expanded, label, links visible, scroll locked
PASS Escape closes and returns focus to the button
PASS outside click closes
PASS link click closes
PASS resize past 1020px closes and releases scroll lock
PASS axe on open menu: zero wcag2a/wcag2aa violations
PASS toggle is 44x44
PASS no JS: links reachable, toggle hidden

The axe step matters because it runs with the panel open. If the panel is closed, the accessibility checker never sees it. Automating accessibility checks shows what that blind spot looks like in numbers.

Failure modes we reproduced

Two of our first test runs failed for reasons in the test, not the menu, and both are common.

The outside-click test clicked inside the panel. Our first version clicked near the bottom of the screen. The panel is position: fixed and covers everything below the header, so that point was inside the panel and the menu correctly stayed open. With a full-height panel, the only “outside” is the header strip. We click at (150, 10) instead.

The resize assertion ran before the event. matchMedia change events fire asynchronously, so reading aria-expanded straight after setViewportSize returned the old value. We now wait for the attribute with page.waitForFunction, and the same test passes.

Then we broke the demo on purpose, one change at a time, and ran the same tests:

MutationTest that failsWhat we measured
Remove button.focus() after EscapeEscape returns focusdocument.activeElement read A, a link in a panel that is no longer displayed
Remove the matchMedia listenerResize past 1020pxAt 1100px: aria-expanded true, html overflow hidden, toggle hidden
Hide the panel without the .js gateNo-JS fallbackWith JavaScript off, the first link was not visible

The second row is the nastiest. At desktop width the toggle is hidden, so the menu cannot be closed, and the page is locked from scrolling even though its content is taller than the viewport. The third row is the reason the js class exists.

How should the panel and motion be styled?

The entrance animation lives only inside @media (prefers-reduced-motion: no-preference). People who have asked their system to reduce motion get the panel with no animation, and people who haven’t get the animation. Writing it this way, with the motion opt-in, is simpler than writing the animation and then trying to switch it off.

Our first panel had a translucent background. Page content ghosted through the menu, and the links were harder to read over the text underneath. We switched to a solid background. If a panel sits on top of content and holds interactive text, it should be opaque.

Checklist

  • Use a real button with aria-expanded, aria-controls, and a label that changes.
  • Add a js class in the head and scope the collapsed menu to it.
  • Route every open and close through one function.
  • Escape closes and returns focus to the toggle.
  • Close on link click, outside tap, and growth to desktop width.
  • Lock page scroll while open, and use a solid panel background.
  • Put animation behind prefers-reduced-motion: no-preference.
  • Test with Playwright, wait for events instead of reading state immediately, and run axe with the menu open.

Frequently asked questions

How do I make a mobile menu accessible without a framework?

Use a real button with aria-expanded, aria-controls and a label that switches between Menu and Close menu. Route every open and close through one function that updates the class, the ARIA state, the label and the scroll lock. Close on Escape, link click, outside tap and growth to desktop width, and return focus to the button on Escape.

Where should focus go when the mobile menu closes?

Back to the toggle button, at least when closing with Escape. In our reproduction, removing the one focus call left document.activeElement pointing at a link inside the panel that was no longer displayed, so a keyboard user would resume from an arbitrary place. Focus return is a single line, and a test can assert it.

What should a mobile menu do without JavaScript?

Show the links. Add a js class to the html element from a tiny inline script in the head, and scope the collapsed-menu CSS to that class. Without scripting the class never appears, so the nav stays visible and every page remains reachable. In our test with JavaScript disabled, the first link was visible and the toggle was hidden.

How do I test a mobile menu with Playwright and axe?

Drive the menu the way a person would: click the toggle, press Escape, click outside, resize past the breakpoint, and assert aria-expanded and document.activeElement each time. Then open the menu and run axe-core, because a closed panel is not rendered and axe never sees its links. Wait for the resize event before asserting.

Why close the menu when the window grows to desktop width?

Because the toggle is hidden at desktop width, so a menu left open could not be closed, and the scroll lock on the html element would stay on. In our reproduction without the matchMedia listener, aria-expanded stayed true and overflow stayed hidden at 1100px. A listener on min-width 1021px closes it.

More from Research

← All research