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'snotastylechoice—it'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.