A desktop navigation bar with six links fits comfortably across a 1280-pixel screen. On a 360-pixel phone, those same links either wrap onto three messy lines or overflow off the edge. The standard solution is the hamburger menu: on small screens the links are hidden behind a button with three lines, and tapping it opens the menu.
Building one well takes a little care. The menu must open and close with a tap, be usable with the keyboard, tell screen readers whether it's open, and switch back to a normal row of links on larger screens. You'll build it mobile first: the collapsed phone menu is the default, and a media query turns it into a horizontal bar on wider screens.
This lesson shows two versions. The first uses a real <button> and three lines of JavaScript to toggle the menu and update aria-expanded, which is the most accessible approach. The second uses <details> and <summary> for a menu that works with no JavaScript at all. Both build on the lists and navbars lesson.
The HTML structure
<header class="site-header">
<a class="logo" href="/">Brand</a>
<button class="menu-toggle" aria-expanded="false" aria-controls="menu">Menu</button>
<nav id="menu" class="menu">
<ul>...links...</ul>
</nav>
</header>- The toggle is a
<button>, so it works with Enter and Space and is announced as a button. aria-expandedtells screen readers whether the menu is open; JavaScript switches it between"true"and"false".aria-controlslinks the button to the menu it opens.- Give the button a visible label or an
aria-labelsuch as "Menu", not just three lines.
Mobile-first CSS
.menu { display: none; } /* phones: hidden */
.menu-toggle[aria-expanded="true"] + .menu { display: block; } /* open */
@media (min-width: 768px) {
.menu-toggle { display: none; } /* no button needed */
.menu { display: block; } /* always visible */
.menu ul { display: flex; gap: 8px; } /* a row of links */
}Styling the open state from the aria-expanded attribute keeps the visual state and the accessibility state in sync: one attribute controls both. If you prefer, toggle a class such as .is-open, but then update aria-expanded too.
The three-line JavaScript toggle
const btn = document.querySelector('.menu-toggle');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
});That's all the JavaScript a basic hamburger menu needs. For a polished version, also close the menu when the user presses Esc and when they choose a link. You'll learn event listeners in JavaScript events.
Drawing the hamburger icon
| Approach | How |
|---|---|
| Inline SVG | Three <line> or <rect> elements; easiest to make sharp and animate |
| CSS lines | One <span> plus ::before and ::after, each a 2px-tall bar |
| Text | The word "Menu", which is clearer for many users than an icon alone |
To morph the icon into an X when open, rotate the top and bottom bars 45 degrees in opposite directions and hide the middle one, using transforms and a short transition.
CSS Responsive Navbar Syntax
Responsive navbar
.site-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; }
.menu { display: none; flex-basis: 100%; }
.menu-toggle[aria-expanded="true"] + .menu { display: block; }
.menu a { display: block; padding: 12px; }
@media (min-width: 768px) {
.menu-toggle { display: none; }
.menu { display: block; flex-basis: auto; }
.menu ul { display: flex; }
}CSS Responsive Navbar Summary
- On small screens, hide the links behind a toggle button; show them as a row on larger screens.
- Use a real
<button>witharia-expandedandaria-controls. - Style the open state from
[aria-expanded="true"]so visuals and accessibility stay in sync. - A few lines of JavaScript flip
aria-expanded;<details>works with no JavaScript. - Keep tap targets large and respect
prefers-reduced-motion.
CSS Responsive Navbar Examples
Each example runs in the page. Edit the code and press Run to see what changes.
A hamburger menu with a toggle button
Make the result panel narrow and press the button. On wide panels the links show in a row.
<header class="site-header">
<a class="logo" href="#">Harbour Books</a>
<button class="menu-toggle" aria-expanded="false" aria-controls="menu" aria-label="Menu">
<span class="bars"></span>
</button>
<nav id="menu" class="menu">
<ul>
<li><a href="#">Books</a></li>
<li><a href="#">Events</a></li>
<li><a href="#">Gift cards</a></li>
<li><a href="#">Visit us</a></li>
</ul>
</nav>
</header>.site-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 8px 12px; background: #0f172a; font-family: sans-serif; }
.logo { color: white; font-weight: 700; text-decoration: none; }
.menu-toggle { width: 44px; height: 44px; border: 0; background: transparent; cursor: pointer; display: grid; place-items: center; }
.bars, .bars::before, .bars::after { display: block; width: 22px; height: 2px; background: white; border-radius: 2px; transition: transform 0.2s, background 0.2s; }
.bars { position: relative; }
.bars::before, .bars::after { content: ""; position: absolute; left: 0; }
.bars::before { top: -7px; } .bars::after { top: 7px; }
.menu-toggle[aria-expanded="true"] .bars { background: transparent; }
.menu-toggle[aria-expanded="true"] .bars::before { transform: translateY(7px) rotate(45deg); }
.menu-toggle[aria-expanded="true"] .bars::after { transform: translateY(-7px) rotate(-45deg); }
.menu { display: none; flex-basis: 100%; }
.menu-toggle[aria-expanded="true"] + .menu { display: block; }
.menu ul { list-style: none; margin: 0; padding: 0; }
.menu a { display: block; padding: 12px 8px; color: #e2e8f0; text-decoration: none; border-radius: 6px; }
.menu a:hover { background: #1e293b; }
@media (min-width: 600px) {
.menu-toggle { display: none; }
.menu { display: block; flex-basis: auto; }
.menu ul { display: flex; gap: 4px; }
}const btn = document.querySelector('.menu-toggle');
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
});Line-by-line explanation
- Line 4A 44px button is easy to tap.
- Lines 5 to 8One span and two pseudo-elements draw the three bars.
- Lines 9 to 11When open, the middle bar fades and the others rotate into an X.
- Lines 13 to 14The menu is hidden until the button says it's expanded, and takes a full row when open.
- Lines 19 to 23From 600px, the button disappears and the links sit in a row.
A menu with no JavaScript
<header class="bar">
<strong>Green Kitchen</strong>
<details class="nav">
<summary>Menu</summary>
<nav>
<a href="#">Recipes</a>
<a href="#">Meal plans</a>
<a href="#">About</a>
</nav>
</details>
</header>.bar { display: flex; justify-content: space-between; align-items: start; padding: 10px 12px; background: #ecfdf5; font-family: sans-serif; }
.nav summary {
list-style: none;
cursor: pointer;
padding: 8px 14px;
border: 1px solid #16a34a;
border-radius: 8px;
font-weight: 600;
}
.nav summary::-webkit-details-marker { display: none; }
.nav[open] summary { background: #16a34a; color: white; }
.nav nav { display: grid; margin-top: 6px; }
.nav a { padding: 10px; color: #14532d; text-decoration: none; }Line-by-line explanation
- Line 4
list-style: noneremoves the default triangle. - Line 11Safari needs this line to hide its triangle too.
- Line 12The
[open]attribute styles the open state. - Line 14The links stack beneath the button.
Respecting reduced motion
<button class="toggle" aria-expanded="false">Menu</button>
<nav class="panel"><a href="#">Home</a><a href="#">Shop</a></nav>.panel {
display: grid;
max-height: 0;
overflow: hidden;
font-family: sans-serif;
}
.toggle[aria-expanded="true"] + .panel { max-height: 200px; }
.panel a { padding: 10px; }
@media (prefers-reduced-motion: no-preference) {
.panel { transition: max-height 0.3s ease; }
}const t = document.querySelector('.toggle');
t.addEventListener('click', () => t.setAttribute('aria-expanded', String(t.getAttribute('aria-expanded') !== 'true')));Line-by-line explanation
- Lines 3 to 4The panel is collapsed until opened.
- Line 7Opening it sets a max height large enough for the links.
- Lines 10 to 12The slide animation only runs for people who haven't asked for reduced motion.
Common CSS Responsive Navbar Mistakes
A div as the toggle
/* <div class="menu-toggle" onclick="...">☰</div> *//* <button class="menu-toggle" aria-expanded="false" aria-label="Menu">☰</button> */A div can't be focused or pressed with the keyboard, and screen readers don't know it's a control.
Hiding the menu on desktop by accident
.menu { display: none; }
.menu.open { display: block; } /* desktop never shows links */.menu { display: none; }
.menu.open { display: block; }
@media (min-width: 768px) { .menu { display: block; } }Forgetting to update aria-expanded
/* JavaScript only toggles .open; aria-expanded stays "false" */
.menu.open { display: block; }.menu-toggle[aria-expanded="true"] + .menu { display: block; }Driving the style from the attribute guarantees screen readers hear the right state.
CSS Responsive Navbar in Real Projects
Navigation on real sites
Almost every mobile site uses a collapsible menu. Large sites such as the BBC and GOV.UK use a button labelled "Menu" with aria-expanded, and test it with screen readers. E-commerce apps often slide the menu in from the side as a drawer. Frameworks such as Bootstrap ship a "navbar-toggler" component built on exactly these ideas.
CSS Responsive Navbar Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Show the menu when the toggle button says it's open.
.menu-toggle[aria-expanded=""] + .menu {
display: block;
}
Which element should open and close a hamburger menu?
In a mobile-first navbar, what does the min-width media query do?
Which HTML gives a collapsible menu with no JavaScript?
The menu should be hidden by default, shown when the button has aria-expanded="true", and always shown from 600px. Fix the CSS.
<button class="tg" aria-expanded="true">Menu</button>
<nav class="mn"><a href="#">Home</a></nav>.mn { display: block; }
.tg[aria-expanded="false"] + .mn { display: block; }
@media (max-width: 600px) { .mn { display: none; } }- Open menu is visible
- Hidden by default
- Always shown on wide screens
CSS Responsive Navbar Quiz
5 questions to check what you learned.
What does aria-expanded="true" tell screen readers?
- The menu is large
- The control's menu is open
- The page is loading
- The button is disabled
How do CSS hamburger icons usually draw three bars?
- One element plus ::before and ::after
- Three images
- Three buttons
- A border-image
Why show the word "Menu" next to or instead of the icon?
- It loads faster
- Icons aren't allowed
- It's needed for SEO
- Some users don't recognise the three-line icon
What should happen when the user presses Esc with the menu open?
- Nothing
- The page reloads
- The menu closes
- The menu moves
How can you stop the menu animation for people who prefer less motion?
- Only add the transition inside @media (prefers-reduced-motion: no-preference)
- @media (motion: off)
- animation: none on body
- It can't be detected
CSS Responsive Navbar Challenge
Make the navbar responsive
- Hide
.linksby default withdisplay: none - Show
.links(display: block) when.togglehasaria-expanded="true" - In the JavaScript, flip
aria-expandedbetween"true"and"false"on click - From
min-width: 700px, hide.toggleand show.linksas a flex row
<header>
<button class="toggle" aria-expanded="false" aria-controls="links">Menu</button>
<nav id="links" class="links"><a href="#">Home</a> <a href="#">Shop</a></nav>
</header>const toggle = document.querySelector('.toggle');
- Links hidden by default
- Click opens the menu
- Click again closes it
- Open state shows the links
- Desktop row in a media query