Most hamburger menu CSS snippets you find online look great in a demo and fall apart the moment someone uses a keyboard or a screen reader. The icon animates, the panel slides in, and then the user presses Tab and the focus disappears behind the overlay with no way out.
This tutorial fixes that. We will build a hamburger menu from scratch: the toggle button markup, the three-bar icon in pure CSS, the slide-in panel, and the JavaScript that opens and closes it properly. Everything is accessible by default: aria-expanded, focus trapping, focus return, Escape key support and reduced-motion handling.
What we are building
- A 44x44px toggle button that animates from three bars into an X.
- A slide-in panel from the right with a dimmed overlay behind it.
- A desktop layout where the button disappears and the links become a normal horizontal nav.
- Full keyboard support: Tab stays inside the open menu, Escape closes it, focus returns to the button.
No framework, no dependency, around 60 lines of JavaScript.

The accessibility checklist most tutorials skip
Before writing a single line, here is what separates a decorative hamburger from a usable one.
| Requirement | How we solve it |
|---|---|
| The control must be announced as a button | Use a real <button type="button">, not a div or a label hack |
| State must be exposed | aria-expanded="false" toggled to true in JS |
| Relationship between button and panel | aria-controls="primary-menu" |
| The icon needs a name | aria-label on the button, aria-hidden on the bars |
| Keyboard users must not get lost | Focus trap while open, focus returned to the toggle on close |
| Escape should dismiss | keydown listener on document |
| Hidden content must really be hidden | visibility: hidden on the closed panel, not just transform |
| Motion sensitivity | @media (prefers-reduced-motion: reduce) |
| Touch target size | Minimum 44×44 CSS pixels |

Step 1: The HTML markup
Keep the structure flat and semantic. The button sits in the header, the panel is a <nav> with an id, and the overlay is a sibling element.
<header class="site-header">
<a class="logo" href="/">Express</a>
<button
type="button"
class="hamburger"
id="menu-toggle"
aria-expanded="false"
aria-controls="primary-menu"
aria-label="Open menu">
<span class="hamburger__box" aria-hidden="true">
<span class="hamburger__bar"></span>
<span class="hamburger__bar"></span>
<span class="hamburger__bar"></span>
</span>
</button>
<nav id="primary-menu" class="nav-panel" aria-label="Main">
<ul>
<li><a href="/docs">Docs</a></li>
<li><a href="/guides">Guides</a></li>
<li><a href="/api">API</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<div class="nav-overlay" hidden></div>
Why these attributes matter
- type=”button”: without it, a button inside a form submits the form.
- aria-expanded: this is the single most important attribute. A screen reader announces “Open menu, button, collapsed” and then “expanded” after the click.
- aria-hidden on the bars: the three spans are decoration. They must never be read out.
- aria-label on the nav: when a page has several nav landmarks, labels let users tell them apart.
Step 2: The hamburger icon in pure CSS
Three absolutely positioned bars inside a relative box. Opening the menu adds a class to <body>, which rotates bar 1 and 3 and fades out bar 2.
:root {
--nav-width: min(78vw, 20rem);
--bar-color: #111827;
--accent: #2563eb;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
position: relative;
z-index: 40;
}
.hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
cursor: pointer;
}
.hamburger:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
.hamburger__box {
position: relative;
display: block;
width: 24px;
height: 18px;
}
.hamburger__bar {
position: absolute;
left: 0;
width: 100%;
height: 2px;
border-radius: 2px;
background: var(--bar-color);
transition: top 0.25s ease, transform 0.25s ease, opacity 0.15s ease;
}
.hamburger__bar:nth-child(1) { top: 0; }
.hamburger__bar:nth-child(2) { top: 8px; }
.hamburger__bar:nth-child(3) { top: 16px; }
.nav-open .hamburger__bar:nth-child(1) { top: 8px; transform: rotate(45deg); }
.nav-open .hamburger__bar:nth-child(2) { opacity: 0; }
.nav-open .hamburger__bar:nth-child(3) { top: 8px; transform: rotate(-45deg); }
Tip: the bars are 2px tall with 8px gaps, which keeps the icon readable at any device pixel ratio. Avoid using the ☰ character as your icon: its rendering changes wildly between fonts and platforms.

Step 3: The slide-in panel CSS
The panel is fixed to the right edge and pushed off screen with translateX(100%). The key accessibility detail is visibility: hidden, which removes the closed links from the tab order and from the accessibility tree. A transform alone leaves them focusable off screen.
.nav-panel {
position: fixed;
inset: 0 0 0 auto;
width: var(--nav-width);
padding: 5rem 1.5rem 2rem;
background: #ffffff;
box-shadow: -8px 0 24px rgba(15, 23, 42, 0.15);
transform: translateX(100%);
visibility: hidden;
overflow-y: auto;
z-index: 30;
transition: transform 0.3s ease, visibility 0.3s;
}
.nav-panel ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.nav-panel a {
display: block;
padding: 0.85rem 0.5rem;
font-size: 1.125rem;
text-decoration: none;
color: #111827;
border-radius: 6px;
}
.nav-panel a:hover { background: #f3f4f6; }
.nav-panel a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.nav-open .nav-panel {
transform: translateX(0);
visibility: visible;
}
.nav-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
z-index: 25;
}
.nav-overlay[hidden] { display: none; }
/* Stop the page behind from scrolling while the menu is open */
body.nav-open { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.nav-panel,
.hamburger__bar {
transition: none;
}
}
Desktop layout
Above the breakpoint, hide the button and the overlay and turn the panel back into a plain horizontal list. Everything is reset so that no mobile state can leak into the desktop view.
@media (min-width: 48em) {
.hamburger,
.nav-overlay {
display: none;
}
.nav-panel {
position: static;
width: auto;
padding: 0;
background: none;
box-shadow: none;
transform: none;
visibility: visible;
overflow: visible;
}
.nav-panel ul {
display: flex;
gap: 1.5rem;
}
body.nav-open { overflow: auto; }
}
Step 4: The JavaScript (toggle, Escape, focus trap)
This is the part copy-paste snippets usually reduce to a single classList.toggle(). Here is the complete version.
const toggle = document.getElementById("menu-toggle");
const panel = document.getElementById("primary-menu");
const overlay = document.querySelector(".nav-overlay");
const desktop = window.matchMedia("(min-width: 48em)");
const FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
function isOpen() {
return toggle.getAttribute("aria-expanded") === "true";
}
function openMenu() {
document.body.classList.add("nav-open");
toggle.setAttribute("aria-expanded", "true");
toggle.setAttribute("aria-label", "Close menu");
overlay.hidden = false;
const first = panel.querySelector(FOCUSABLE);
if (first) first.focus();
document.addEventListener("keydown", onKeydown);
}
function closeMenu(returnFocus) {
document.body.classList.remove("nav-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "Open menu");
overlay.hidden = true;
document.removeEventListener("keydown", onKeydown);
if (returnFocus) toggle.focus();
}
function onKeydown(event) {
if (event.key === "Escape") {
closeMenu(true);
return;
}
if (event.key !== "Tab") return;
const items = Array.from(panel.querySelectorAll(FOCUSABLE))
.filter(el => el.offsetParent !== null);
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === toggle)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
toggle.focus();
}
}
toggle.addEventListener("click", function () {
if (isOpen()) {
closeMenu(true);
} else {
openMenu();
}
});
overlay.addEventListener("click", function () {
closeMenu(true);
});
/* Close after a link is activated (useful for same-page anchors) */
panel.addEventListener("click", function (event) {
if (event.target.closest("a") && isOpen()) closeMenu(false);
});
/* Reset state when the viewport grows past the breakpoint */
desktop.addEventListener("change", function (event) {
if (event.matches && isOpen()) closeMenu(false);
});
What each block does
- openMenu() sets the state attribute, swaps the accessible name, reveals the overlay and moves focus to the first link.
- closeMenu() reverses everything and sends focus back to the toggle so the keyboard user is not dropped at the top of the document.
- onKeydown() handles Escape and wraps Tab and Shift+Tab around the panel. The toggle button is deliberately included in the loop so the user can always reach the close control.
- The media query listener prevents the dreaded bug where you open the menu on a phone, rotate to landscape, and the body stays locked with
overflow: hidden.
Bonus: hide the background from screen readers
A focus trap stops Tab, but a screen reader can still browse the page behind the panel with virtual cursor keys. The inert attribute solves this in one line. Add it to your main content wrapper when the menu opens:
const main = document.querySelector("main");
// inside openMenu()
if (main) main.inert = true;
// inside closeMenu()
if (main) main.inert = false;

Why not a pure CSS hamburger menu?
The checkbox hack (a hidden <input type="checkbox"> plus the :checked selector) is everywhere, and it is genuinely clever. But it has real costs, and knowing them helps you choose on purpose instead of by accident. center-key/hamburger-menu: ☰ A responsive CSS solution is a useful companion to this.
| Feature | CSS-only (checkbox hack) | HTML + CSS + JS (this guide) |
|---|---|---|
| Works without JavaScript | Yes | No (add a no-js fallback) |
| Announced state (expanded / collapsed) | Announced as a checkbox, which is confusing | Correct button semantics |
| Escape key closes | No | Yes |
| Focus trap and focus return | No | Yes |
| Close on outside click | Only with an extra label overlay | Yes |
| Lines of code | Fewer | About 60 extra lines |
Short version: use the checkbox hack for a personal demo page, use the scripted version for anything a real user will navigate.
A modern middle ground: the popover attribute
Native HTML popovers are now supported across all major evergreen browsers and give you light dismiss and Escape handling for free. The markup is minimal:
<button type="button" popovertarget="primary-menu" aria-label="Menu">...</button>
<nav id="primary-menu" popover aria-label="Main"> ... </nav>
The browser handles opening, closing on Escape, and closing on outside click. You still want to reflect the state with aria-expanded through the beforetoggle event, and you still style the open state with the :popover-open selector. It is a great option for new projects, while the JavaScript version above remains the safest choice when you need total control over animation and focus order.
Step 5: Test it in five minutes
- Unplug the mouse. Tab to the button, press Enter, then Tab through every link. Focus must never leave the panel.
- Press Escape. The panel closes and the focus ring is back on the hamburger button.
- Inspect the button in DevTools while toggling:
aria-expandedmust flip betweentrueandfalse. - Zoom to 200% and check that the panel still scrolls and nothing is clipped.
- Enable reduced motion in your OS settings and confirm the slide animation is gone.
- Turn on a screen reader (VoiceOver, NVDA or TalkBack) and listen to the announcement: “Open menu, button, collapsed”.

Common mistakes to avoid
- Using
divora href="#"for the toggle. You then have to re-implement Enter, Space and role handling yourself. - Hiding the panel with
opacity: 0ortranslateXonly. The links stay focusable and keyboard users tab into invisible content. - Putting
aria-expandedon the panel. It belongs on the control, not on the thing being controlled. - Adding
role="navigation"to a<nav>. Redundant, the element already carries that role. - Forgetting to unlock
body { overflow: hidden }on resize or on route change in a single page app. - Touch targets under 44px. A 24px icon needs padding around it.
- Removing the focus outline without providing a visible replacement.
FAQ
How do you make a hamburger menu in CSS?
Create a container with three span elements positioned absolutely at 0, 8px and 16px from the top, each 2px tall. Then add a state class (for example .nav-open) that rotates the first bar 45 degrees, the third bar minus 45 degrees, moves both to the center and sets the middle bar to opacity: 0. The slide-in panel uses position: fixed with transform: translateX(100%) when closed and translateX(0) when open.
What is the HTML code for a hamburger menu icon?
The most reliable version is a button containing three empty decorative spans, as shown in step 1. If you prefer an icon font free approach, an inline SVG with three <line> elements and aria-hidden="true" works equally well. Always wrap the icon in a real <button> with an aria-label.
What is the character code for the hamburger icon?
The Unicode character is TRIGRAM FOR HEAVEN, U+2630, written as ☰ or ☰ in HTML. It is not an ASCII character. It renders inconsistently across fonts and is read aloud strangely by some screen readers, so prefer CSS bars or an SVG for production. Someone has put together a good summary of it.
Which websites use a hamburger menu?
Almost every large responsive site uses one on small viewports: news sites, documentation portals, ecommerce stores and most SaaS dashboards. On desktop, many products have moved back to a visible horizontal nav and reserve the hamburger for secondary or overflow navigation, because a visible menu generally produces higher engagement with the links.
Should the hamburger icon include the word “Menu”?
If you have room, yes. Usability testing consistently shows that a labelled button (icon plus the word Menu) is discovered faster than the bare icon. If you keep the icon alone, the aria-label becomes essential for screen reader users. There’s a good explainer over at foolishdeveloper.com.
Does a hamburger menu hurt SEO?
No. Links inside a panel hidden with CSS are still crawled and followed, as long as they exist in the HTML and are not injected only after a user interaction. Keep the links in the markup, avoid building the menu entirely in JavaScript after load, and you are fine.
Wrapping up
A hamburger menu is only four moving parts: a button that reports its state, a panel that is truly hidden when closed, a transition that respects user preferences, and a small amount of JavaScript to manage focus. Copy the three code blocks above into your project, run through the five minute test, and you have a navigation pattern that works for every visitor, not just the ones holding a mouse.
