Skip to contact form
← BlogEngineeringFebruary 7, 2027 · 5 min read

An accessible mobile menu: focus, scroll lock, and Escape

The three things a mobile nav overlay has to get right that most tutorials skip. Tap the icon below, then try pressing Escape.

mobile hamburger menu looks like a solved problem — toggle a boolean, slide a panel in, done. Most implementations that stop there are missing at least one of three things a real overlay needs: the page behind it has to stop scrolling, the keyboard has to be able to close it without hunting for a tiny X button, and the visual state (hamburger vs. X) has to actually reflect whether it's open, not just visually but to assistive tech too.

Anopenmenuownsthescreen.That'snotastylechoiceit'sacontractwiththreeseparateimplications,andskippinganyoneofthemisarealbug,notanitpick.

Try it

tap the icon — try Escape too

Tap the hamburger and it morphs into an X — two motion.span lines rotating into a cross via animate, not a font-icon swap. Once it's open, try pressing Escape; it closes the same way tapping the icon does. That's not a browser default for a custom overlay — it's a keydown listener attached only while the menu is open, and removed the moment it closes, so it never intercepts Escape anywhere else on the page.

typescript

useEffect(() => { if (!open) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; // scroll lock const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); // keyboard close }; window.addEventListener('keydown', onKey); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener('keydown', onKey); }; }, [open]);

Why the scroll lock has to save the previous value

Setting overflow: 'hidden' on open and overflow: '' on close looks equivalent to saving and restoring the previous value, until something else on the page also manages document.body.style.overflow — a different modal, a loading state. Capturing previousOverflow before overwriting it and restoring exactly that value on cleanup means this effect never assumes it's the only code with an opinion about body scroll, even though today, on this site, it happens to be.

aria-expanded is not optional decoration

The toggle button carries aria-expanded={open} and an aria-label that changes between "Open menu" and "Close menu" — not because it changes what the button looks like, but because a screen reader user gets zero visual information from the hamburger-to-X morph. Without aria-expanded, that same user has no way to know whether pressing the button again opens or closes something; the two icon states carry meaning only sighted users can see unless the accessibility tree says it explicitly.