// pattern
Accessible Responsive Navigation
A responsive navigation bar that collapses into a hamburger menu on small screens. Uses a native <button> for the toggle, aria-expanded for state, and proper focus management for keyboard and screen reader users.
// 01 · live demo
Live Demo
Toggle between desktop and mobile views to see the navigation adapt.
Inaccessible version (div-based hamburger):
This version uses a <div> for the hamburger — no keyboard support, no ARIA, no focus management. Try tabbing to it: you can't.
// 02 · the code
The Code
<nav aria-label="Main navigation">
<div class="nav-bar">
<a href="/" class="nav-logo">AcmeCo</a>
<!-- Desktop links (hidden on mobile via CSS) -->
<ul class="nav-links">
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<!-- Hamburger button (hidden on desktop via CSS) -->
<button type="button"
class="nav-hamburger"
aria-expanded="false"
aria-controls="mobile-menu"
aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<!-- Mobile menu panel -->
<div id="mobile-menu" class="nav-mobile-panel"
aria-hidden="true">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
</nav>
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
}
.nav-logo {
font-weight: 700;
font-size: 1.125rem;
color: inherit;
text-decoration: none;
}
/* Desktop links */
.nav-links {
display: flex;
list-style: none;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.nav-links a {
padding: 0.5rem 0.75rem;
color: #4b5563;
text-decoration: none;
font-weight: 500;
border-radius: 0.25rem;
/* SC 2.5.8: adequate target size */
min-height: 2.75rem;
display: inline-flex;
align-items: center;
}
.nav-links a:hover {
color: #1f2937;
background: rgba(0, 0, 0, 0.05);
}
/* SC 2.4.13: visible focus indicator */
.nav-links a:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
}
/* Hamburger button — hidden on desktop */
.nav-hamburger {
display: none;
background: none;
border: 2px solid #d1d5db;
border-radius: 0.25rem;
padding: 0.5rem;
cursor: pointer;
/* SC 2.5.8: 44x44 minimum target */
min-width: 2.75rem;
min-height: 2.75rem;
align-items: center;
justify-content: center;
}
.nav-hamburger svg {
width: 24px;
height: 24px;
}
.nav-hamburger:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: 2px;
}
/* Mobile panel — hidden by default */
.nav-mobile-panel {
display: none;
border-top: 1px solid #d1d5db;
}
.nav-mobile-panel[aria-hidden="false"] {
display: block;
animation: slide-down 0.2s ease-out;
}
.nav-mobile-panel ul {
list-style: none;
margin: 0;
padding: 0.5rem 0;
}
.nav-mobile-panel a {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: #4b5563;
text-decoration: none;
font-weight: 500;
/* SC 2.5.8: adequate target size */
min-height: 2.75rem;
}
.nav-mobile-panel a:hover {
color: #1f2937;
background: rgba(0, 0, 0, 0.05);
}
.nav-mobile-panel a:focus-visible {
outline: 2px solid #5b2a86;
outline-offset: -2px;
}
@keyframes slide-down {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive breakpoint */
@media (max-width: 768px) {
.nav-links {
display: none;
}
.nav-hamburger {
display: inline-flex;
}
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.nav-mobile-panel[aria-hidden="false"] {
animation: none;
}
}
function initResponsiveNav(navElement) {
const hamburger = navElement.querySelector(
'[aria-controls]'
);
const panelId = hamburger.getAttribute('aria-controls');
const panel = document.getElementById(panelId);
const menuLinks = panel.querySelectorAll('a');
function openMenu() {
hamburger.setAttribute('aria-expanded', 'true');
panel.setAttribute('aria-hidden', 'false');
// Focus the first link in the menu
if (menuLinks.length) {
menuLinks[0].focus();
}
}
function closeMenu() {
hamburger.setAttribute('aria-expanded', 'false');
panel.setAttribute('aria-hidden', 'true');
// Return focus to the hamburger button
hamburger.focus();
}
function isOpen() {
return hamburger.getAttribute('aria-expanded')
=== 'true';
}
// Toggle on button click
hamburger.addEventListener('click', () => {
if (isOpen()) {
closeMenu();
} else {
openMenu();
}
});
// Close on Escape
navElement.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) {
closeMenu();
}
});
// Close when Shift+Tab past the first link
menuLinks[0]?.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && e.shiftKey && isOpen()) {
e.preventDefault();
closeMenu();
}
});
// Close when Tab past the last link
menuLinks[menuLinks.length - 1]
?.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.shiftKey
&& isOpen()) {
e.preventDefault();
closeMenu();
}
});
// Close on click outside
document.addEventListener('click', (e) => {
if (isOpen()
&& !navElement.contains(e.target)) {
closeMenu();
}
});
}
// Initialize
document.querySelectorAll('nav[aria-label]')
.forEach(initResponsiveNav);
<ul> and mobile panel contain the same links. This is simpler than dynamically moving the list between containers. CSS handles which set is visible. Screen readers on desktop never encounter the mobile panel because it has aria-hidden="true", and the hamburger button is hidden with display: none.
// 03 · why these decisions
Why These Decisions
Why a <button> for the hamburger
A native <button> element is focusable and keyboard-activatable by default. Users can reach it with Tab and activate it with Enter or Space without any extra JavaScript. A <div> or <span> with a click handler is invisible to keyboard users and screen readers unless you manually add tabindex, role="button", and keydown listeners — unnecessary work that a native button handles for free.
Why aria-expanded on the button
The aria-expanded attribute tells screen readers whether the menu controlled by this button is currently open or closed. Without it, a screen reader user activates the button and has no way to know if anything happened. With it, they hear "Menu, expanded, button" or "Menu, collapsed, button" — immediate confirmation of the toggle state.
Why aria-controls pointing to the menu
The aria-controls attribute establishes a programmatic relationship between the hamburger button and the menu panel it controls. Some screen readers (notably JAWS) allow users to jump directly from the button to the controlled element. Even where this isn't actively used, it documents the relationship in the accessibility tree, making the component's structure clear.
Why aria-label="Menu"
The hamburger button contains only an SVG icon — three horizontal lines. There is no visible text for screen readers to announce. Without aria-label, the button would be announced as just "button" with no indication of its purpose. The label "Menu" is concise and universally understood, giving screen reader users the same understanding that sighted users get from the icon.
Why focus the first link on open
When a user opens the mobile menu, they expect to interact with its contents immediately. Moving focus to the first link eliminates the need to press Tab to find the menu items. This matches the behavior users expect from any dismissable overlay — opening it should put you inside it, not leave you at the trigger.
Why Escape to close
The Escape key is the standard convention for dismissing overlays, dropdowns, and modal-like components. Keyboard users and screen reader users expect it. Without Escape support, users must reverse-tab back to the hamburger button or click outside — a less discoverable and less efficient interaction. Returning focus to the trigger after closing ensures the user doesn't lose their place on the page.
// 04 · keyboard interaction
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Navigates between links on desktop, or to the hamburger button on mobile |
| Enter / Space | Opens or closes the mobile menu when focus is on the hamburger button |
| Escape | Closes the mobile menu and returns focus to the hamburger button |
| Tab (menu open) | Moves focus through the menu links. Tabbing past the last link closes the menu and returns focus to the hamburger. |
| Shift + Tab (on first link) | Closes the menu and returns focus to the hamburger button |
// 05 · wcag 2.2 success criteria
WCAG 2.2 Success Criteria
This pattern satisfies the following WCAG 2.2 success criteria:
-
2.4.1 Bypass Blocks
Level A
— The
<nav>element creates a navigation landmark that screen readers can skip past. A skip link before the navigation allows keyboard users to bypass it entirely. - 2.1.1 Keyboard Level A — The hamburger button and all navigation links are fully keyboard operable. The menu can be opened, navigated, and closed without a mouse.
-
1.3.1 Info and Relationships
Level A
— The
<nav>element provides a navigation landmark. Links are in an unordered list, conveying that they are a group of related items. The button'saria-expandedstate communicates the menu's visibility. - 2.4.3 Focus Order Level A — Focus is explicitly managed when the menu opens (first link) and closes (back to hamburger button), maintaining a logical and predictable focus sequence.
-
4.1.2 Name, Role, Value
Level A
— The hamburger button exposes its name ("Menu" via
aria-label), role (button), and value (aria-expandedstate) to the accessibility tree. - 2.4.13 Focus Appearance Level AAA — Both the hamburger button and navigation links have visible focus indicators using a 2px solid outline with sufficient contrast.
-
2.5.8 Target Size (Minimum)
Level AA
— The hamburger button is at least 44x44px. Navigation links use
min-height: 2.75remwith adequate padding to meet the minimum target size.
// 06 · screen reader behavior
Screen Reader Behavior
When navigating to the navigation landmark
- NVDA: "Demo navigation, landmark" — announces the navigation region by its
aria-label - JAWS: "Demo navigation region" — identifies the landmark and its label
- VoiceOver: "Demo navigation" — announces the navigation landmark
When focusing the hamburger button
- NVDA: "Menu, button, collapsed" — announces the label, role, and expanded state
- JAWS: "Menu, button, collapsed" — same information in the same order
- VoiceOver: "Menu, collapsed, button" — announces label, state, then role
When opening the menu
- NVDA: "Menu, button, expanded" — confirms the state change, then "Home, link, list 5 items" — focus moves to the first link and the list context is announced
- JAWS: "Menu, button, expanded" then "Home, link, list of 5 items" — similar announcement with list count
- VoiceOver: "Menu, expanded, button" — state change confirmed, then "Home, link, 1 of 5" — announces the focused link and its position in the list
When closing the menu
- NVDA: "Menu, button, collapsed" — focus returns to the button and the closed state is announced
- JAWS: "Menu, button, collapsed" — confirms the menu is closed
- VoiceOver: "Menu, collapsed, button" — state change and focus return confirmed
// 07 · common mistakes
Common Mistakes
<div> instead of <button> for the hamburger
A <div> with a click handler is not focusable, not keyboard-activatable, and has no implicit role. Keyboard users cannot reach it, and screen readers ignore it entirely. Always use a native <button> element for interactive toggle controls.
aria-expanded on the toggle
Without aria-expanded, screen reader users activate the hamburger button and have no way to know whether the menu opened or closed. The button state is never communicated, making the entire interaction opaque to assistive technology users.
aria-hidden="true" to the entire <nav> element when the mobile menu is collapsed. This hides the hamburger button itself, making it impossible for screen reader users to find and open the menu. Only hide the menu panel, not the entire navigation landmark.
<a> elements without a containing <ul>, screen readers cannot announce "list of 5 items." This count helps users understand the scope of the navigation. Always wrap navigation links in an unordered list.
// 08 · native html vs. aria
Native HTML vs. ARIA
Responsive navigation relies heavily on native HTML elements. ARIA is needed only for the hamburger button's expanded state and the relationship to the menu panel.
| Feature | Native HTML | ARIA Requirement |
|---|---|---|
| Navigation landmark | <nav> — creates a landmark automatically |
role="navigation" — unnecessary when using <nav> |
| Link list | <ul> — groups links, screen readers announce count |
None needed |
| Hamburger trigger | <button> — focusable, keyboard-activatable |
aria-expanded, aria-controls, aria-label |
| Open/closed state | None — no native toggle state for buttons | aria-expanded="true/false" |
| Button-to-menu relationship | None | aria-controls pointing to the menu panel id |
| Button label | Visible text (if present) | aria-label="Menu" — needed because the icon has no text |
| Menu visibility | display: none via CSS — hides from all users |
aria-hidden as a semantic complement to CSS hiding |
| Navigation label | <nav> provides the landmark |
aria-label on <nav> — distinguishes multiple navs on the same page |
<nav> element, <ul> list, and <button> trigger do the heavy lifting. ARIA is only needed for three things: announcing the toggle state (aria-expanded), connecting the button to its panel (aria-controls), and labeling the icon-only button (aria-label). Native HTML first, ARIA only when needed.