Almost every website has one, and almost every website gets it wrong. The main navigation is the first interactive component a keyboard or screen reader user meets, and when it fails, the rest of the site is effectively locked. In this tutorial we build an accessible navigation menu from scratch: a responsive nav bar with a hamburger toggle and a dropdown submenu, using plain HTML, CSS and JavaScript.
The approach is deliberately different from most guides. We start with the broken version you will find on thousands of production sites, then repair one accessibility defect at a time so you can see exactly what each fix does and why it matters. At the end you get the complete, copy-paste ready code plus a screen reader testing checklist.
What Makes a Navigation Menu Accessible?
Before writing code, here is the target. An accessible navigation menu must satisfy all of the following:
| Requirement | Why it matters |
|---|---|
Semantic markup (<nav>, <ul>, <a>, <button>) |
Screen readers announce a landmark, a list and an item count before the user commits to reading it |
| Operable with the keyboard alone | Tab, Shift+Tab, Enter, Space and Escape must reach and control every item |
| Visible focus indicator | Sighted keyboard users need to know where they are (WCAG 2.4.7) |
State communicated with ARIA (aria-expanded, aria-current) |
Open, closed and current page states are invisible to assistive tech otherwise |
| Predictable focus management | Focus must never be lost, trapped by accident, or sent somewhere surprising |
| Sufficient target size and contrast | Motor and low vision users on touch screens |
| A skip link | Lets repeat visitors bypass the whole block (WCAG 2.4.1) |

Step 0: The Broken Navigation Menu
Here is the pattern that shows up in code reviews every single week. It looks fine, it works with a mouse, and it is unusable for a large slice of your audience.
<div class="nav">
<div class="hamburger" onclick="toggleMenu()">
<img src="menu-icon.svg">
</div>
<div class="menu" id="menu">
<a href="/">Home</a>
<a href="/about">About</a>
<div class="dropdown">
<a href="#" class="drop-link">Products</a>
<div class="submenu">
<a href="/products/bags">Bags</a>
<a href="/products/pens">Pens</a>
<a href="/products/apparel">Apparel</a>
</div>
</div>
<a href="/contact">Contact</a>
</div>
</div>
.nav a:focus { outline: none; }
.submenu { display: none; }
.dropdown:hover .submenu { display: block; }
Every Defect in That Snippet
- No landmark. A
<div class="nav">is invisible to the landmark navigation feature of screen readers. - No list. Without
<ul>/<li>, users never hear “list of 4 items”, so they cannot judge the size of the menu. - A div as a button. The hamburger is not focusable, does not respond to Enter or Space, and has no role or name.
- Icon with no accessible name. The
<img>has noalt, so it is announced as the file name or nothing at all. - Hover only dropdown. Keyboard and touch users can never open the submenu.
- Link used as a trigger.
<a href="#">promises navigation and delivers a toggle. - No state exposed. Nothing tells assistive tech whether the menu or submenu is open or closed.
- Focus outline removed.
outline: nonewith no replacement is a straight WCAG 2.4.7 failure. - Hidden links stay in the tab order.
display: noneis fine here, but the moment someone usesopacity: 0orvisibilitytricks, invisible links become tab traps. - No current page indication. Colour alone, if present at all, fails WCAG 1.4.1.
Now let us fix these, one at a time.
Step 1: Fix the Semantics
The single highest impact change costs nothing in design terms. Wrap the menu in a <nav> element, give it an accessible name, and use a real list. How to build an accessible navigation menu tackles the same question from another angle.
<nav class="site-nav" aria-label="Main">
<ul class="nav-list" id="primary-menu">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
Two rules for the label:
- Use
aria-label="Main", notaria-label="Main navigation". The role is already announced, so “navigation navigation” is noise. - If the page has more than one
<nav>(footer, breadcrumb, sidebar), every one of them needs a unique name. That is how users tell them apart in the landmarks list.
Step 2: Add a Skip Link
A keyboard user should not tab through 25 links on every page load. Place this as the first focusable element in the body.
<a class="skip-link" href="#main">Skip to main content</a>
...
<main id="main" tabindex="-1"> ... </main>
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: #111;
color: #fff;
padding: 0.75rem 1rem;
z-index: 100;
}
.skip-link:focus {
left: 0;
}
The tabindex="-1" on <main> makes the target programmatically focusable so focus actually moves, instead of only the scroll position changing.

Step 3: Indicate the Current Page
Use aria-current="page" on the link that matches the current URL, and back it up with a visual style that is not colour alone. A fuller account is out there.
<li><a href="/about" aria-current="page">About</a></li>
.nav-list a[aria-current="page"] {
font-weight: 700;
border-bottom: 3px solid currentColor;
}
Step 4: Restore a Visible Focus Indicator
Never delete the outline without replacing it. A good modern pattern uses :focus-visible so mouse users are not distracted while keyboard users get a strong indicator.
.site-nav a:focus-visible,
.site-nav button:focus-visible {
outline: 3px solid #0b5fff;
outline-offset: 2px;
border-radius: 2px;
}
Aim for at least a 3:1 contrast ratio between the indicator and the adjacent background, and make sure the indicator is not clipped by overflow: hidden on a parent.
Step 5: Turn the Hamburger Into a Real Button
The mobile toggle must be a native <button>. You get focusability, Enter and Space handling, and the correct role for free.
<button class="nav-toggle"
type="button"
aria-expanded="false"
aria-controls="primary-menu">
<svg aria-hidden="true" focusable="false" width="24" height="24" viewBox="0 0 24 24">
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="toggle-text">Menu</span>
</button>
Key points:
type="button"prevents accidental form submission.- The SVG is decorative, so it gets
aria-hidden="true"andfocusable="false"(the latter for legacy Internet Explorer style behaviour in some engines). - The visible word “Menu” is the accessible name. If your design is icon only, use a visually hidden span instead of
aria-labelwhere you can, and never leave the button unnamed. aria-expandedis toggled in JavaScript. This is what makes a screen reader say “Menu, button, collapsed” then “expanded”.aria-controlspoints at the id of the element being shown. Support is patchy but it is harmless and helps some users.
Do Not Use role="menu" Here
This is the most common over-engineering mistake. The menu, menubar and menuitem roles come from desktop application UI, like the File and Edit menus in a word processor. Using them on a website nav bar changes user expectations: arrow keys become the only way to move, Tab exits the whole widget, and links stop being announced as links.
| Pattern | Use it for | Keyboard model |
|---|---|---|
Disclosure navigation (button + aria-expanded) |
Site navigation with links, the right choice in 95% of cases | Tab moves between items, Enter/Space toggles, Escape closes |
Menubar (role="menubar", role="menuitem") |
Application toolbars that fire commands, not page links | Single tab stop, arrow keys move, Tab leaves the widget |
We build the disclosure pattern below because a website menu is a list of links, not a set of application commands.

Step 6: Build the Dropdown as a Disclosure
Replace the fake <a href="#"> trigger with a button, and hide the panel with the hidden attribute so its links leave the tab order when closed.
<li class="has-dropdown">
<button class="dropdown-toggle"
type="button"
aria-expanded="false"
aria-controls="products-submenu">
Products
<span class="chevron" aria-hidden="true"></span>
</button>
<ul class="submenu" id="products-submenu" hidden>
<li><a href="/products/bags">Bags</a></li>
<li><a href="/products/pens">Pens</a></li>
<li><a href="/products/apparel">Apparel</a></li>
</ul>
</li>
Warning: the hidden attribute is easily defeated by CSS. If your stylesheet sets .submenu { display: flex; }, the browser will show a hidden element. Always add this safety rule:
[hidden] { display: none !important; }
What About Hover?
You can keep hover as a convenience, but it must never be the only way in. Also give hovered dropdowns a small close delay so users with tremors do not lose the panel when the pointer strays by two pixels. Better still, follow WCAG 1.4.13 and make the content dismissible with Escape and hoverable without disappearing.
Step 7: The JavaScript for Keyboard and Focus Management
This is the whole script. It is dependency free and under 70 lines.
const nav = document.querySelector('.site-nav');
const navToggle = document.querySelector('.nav-toggle');
const navList = document.getElementById('primary-menu');
const dropdownToggles = nav.querySelectorAll('.dropdown-toggle');
/* --- Mobile hamburger --- */
navToggle.addEventListener('click', () => {
const isOpen = navToggle.getAttribute('aria-expanded') === 'true';
navToggle.setAttribute('aria-expanded', String(!isOpen));
navList.classList.toggle('is-open', !isOpen);
});
/* --- Dropdowns --- */
function closeDropdown(btn) {
btn.setAttribute('aria-expanded', 'false');
document.getElementById(btn.getAttribute('aria-controls')).hidden = true;
}
function closeAllDropdowns(except) {
dropdownToggles.forEach((btn) => {
if (btn !== except) closeDropdown(btn);
});
}
dropdownToggles.forEach((btn) => {
const panel = document.getElementById(btn.getAttribute('aria-controls'));
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
closeAllDropdowns(btn);
btn.setAttribute('aria-expanded', String(!isOpen));
panel.hidden = isOpen;
});
/* Arrow Down from the trigger moves into the first submenu link */
btn.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
btn.setAttribute('aria-expanded', 'true');
panel.hidden = false;
panel.querySelector('a').focus();
}
});
/* Arrow keys inside the submenu */
panel.addEventListener('keydown', (e) => {
const links = Array.from(panel.querySelectorAll('a'));
const i = links.indexOf(document.activeElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
links[(i + 1) % links.length].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
links[(i - 1 + links.length) % links.length].focus();
}
if (e.key === 'Home') { e.preventDefault(); links[0].focus(); }
if (e.key === 'End') { e.preventDefault(); links[links.length - 1].focus(); }
});
});
/* --- Escape closes and returns focus to the trigger --- */
nav.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const openBtn = nav.querySelector('.dropdown-toggle[aria-expanded="true"]');
if (openBtn) {
closeDropdown(openBtn);
openBtn.focus();
} else if (navToggle.getAttribute('aria-expanded') === 'true') {
navToggle.setAttribute('aria-expanded', 'false');
navList.classList.remove('is-open');
navToggle.focus();
}
});
/* --- Tabbing or clicking outside closes everything --- */
document.addEventListener('focusin', (e) => {
if (!nav.contains(e.target)) closeAllDropdowns();
});
document.addEventListener('click', (e) => {
if (!nav.contains(e.target)) closeAllDropdowns();
});
The Focus Management Rules That Matter
- Escape must return focus to the trigger. If you close a panel while focus is inside it, focus falls back to
<body>and the user is dumped at the top of the page. - Do not trap focus in a nav bar dropdown. Tab should flow naturally to the next item. Focus trapping belongs in modal dialogs only.
- A full screen mobile menu is closer to a dialog. If your mobile menu covers the whole viewport, add
inertoraria-hidden="true"to the rest of the page while it is open, and trap focus inside it. - Never move focus on hover. Only user intent (click, Enter, Space, arrow key) should move focus.
Step 8: Responsive CSS Without Breaking Accessibility
[hidden] { display: none !important; }
.site-nav { position: relative; }
.nav-list {
list-style: none;
margin: 0;
padding: 0;
display: none;
}
.nav-list.is-open { display: block; }
.nav-list a,
.dropdown-toggle {
display: block;
padding: 0.75rem 1rem; /* keeps targets at least 44px tall */
min-height: 44px;
font: inherit;
color: #14213d;
background: none;
border: 0;
text-decoration: none;
cursor: pointer;
}
.submenu { list-style: none; margin: 0; padding-left: 1rem; }
@media (min-width: 48em) {
.nav-toggle { display: none; }
.nav-list { display: flex; gap: 0.5rem; }
.has-dropdown { position: relative; }
.submenu {
position: absolute;
top: 100%;
left: 0;
min-width: 12rem;
background: #fff;
border: 1px solid #d0d0d0;
box-shadow: 0 6px 18px rgba(0,0,0,.12);
padding-left: 0;
z-index: 10;
}
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
Three details people miss:
- Targets of at least 44 by 44 CSS pixels satisfy WCAG 2.5.8 comfortably and help everyone on a phone.
- The dropdown panel needs an opaque background and a border, otherwise text overlaps page content in Windows High Contrast Mode.
- Honour
prefers-reduced-motion. Sliding menus trigger nausea for users with vestibular disorders.

How to Test Your Accessible Navigation Menu
Keyboard Test (2 Minutes, No Tools Needed)
- Put the mouse down. Press Tab from the very top of the page. The skip link should appear first.
- Tab into the nav. Can you see where focus is at every single step?
- Press Enter or Space on the dropdown trigger. Does the panel open?
- Press Arrow Down and Arrow Up inside the panel. Does focus cycle through the links?
- Press Escape. Does the panel close and focus return to the trigger?
- Shift+Tab backwards through the whole menu. Anything stuck? Anything invisible receiving focus?
- Resize to mobile width. Repeat with the hamburger.
Screen Reader Testing
Automated scanners catch roughly a third of issues. You have to listen to the component. Test at least one Windows and one Apple combination.
| Screen reader | Pair with | Key shortcut for navigation |
|---|---|---|
| NVDA (free, Windows) | Firefox or Chrome | D cycles landmarks, Insert+F7 opens the elements list |
| JAWS (Windows) | Chrome or Edge | R cycles regions, Insert+F6 lists headings |
| VoiceOver (macOS) | Safari | VO+U opens the rotor, then arrow to Landmarks |
| VoiceOver (iOS) | Safari | Swipe right to move, double tap to activate |
| TalkBack (Android) | Chrome | Swipe right to move, double tap to activate |
What you should hear when everything is correct:
- “Main, navigation, list, 4 items”
- “Home, link” then “About, link, current page”
- “Products, button, collapsed” and after activation “Products, button, expanded”
- “Bags, link, 1 of 3”
- “Menu, button, collapsed” on the hamburger, never “clickable” or “graphic”
Quick Automated Checks
- axe DevTools or Lighthouse for the obvious violations (missing names, contrast, duplicate ids)
- The browser accessibility tree inspector in Chrome or Firefox DevTools to confirm roles and states change live
- Zoom the browser to 400% and confirm the menu still works, with no horizontal scrolling (WCAG 1.4.10)
- Force Windows High Contrast Mode and check that the dropdown remains readable
Common Mistakes Cheat Sheet
| Mistake | Fix |
|---|---|
aria-expanded on the panel instead of the trigger |
It belongs on the element the user activates |
role="button" on a div |
Use a real <button> element |
Hiding the menu with opacity: 0 or visibility: visible + off screen |
Use hidden, display: none or the inert attribute |
| Announcing “Main navigation navigation” | Drop the word navigation from aria-label |
Adding role="navigation" to a <nav> |
Redundant, remove it |
Empty href="#" triggers |
Buttons toggle, links navigate |
| Three level deep flyouts | Flatten to two levels, or use a mega menu with headings |
| Different link order on mobile and desktop | Keep one DOM order (WCAG 3.2.3 consistent navigation) |
WCAG Criteria This Menu Satisfies
| Criterion | Level | Handled by |
|---|---|---|
| 1.3.1 Info and Relationships | A | nav landmark, lists, real buttons |
| 2.1.1 Keyboard / 2.1.2 No Keyboard Trap | A | Buttons, arrow keys, Escape handler |
| 2.4.1 Bypass Blocks | A | Skip link |
| 2.4.7 Focus Visible | AA | :focus-visible outline |
| 1.4.13 Content on Hover or Focus | AA | Dismissible, hoverable, persistent dropdown |
| 3.2.3 Consistent Navigation | AA | Same DOM order on all breakpoints |
| 4.1.2 Name, Role, Value | A | aria-expanded, button names, aria-current |
| 2.5.8 Target Size (Minimum) | AA | 44px minimum tap targets |
Frequently Asked Questions
Do I need JavaScript for an accessible navigation menu?
No, not for a single level nav bar. A <nav> with a list of links is accessible with zero script. You only need JavaScript once you introduce show and hide behaviour, because aria-expanded has to be updated. A CSS only checkbox hack can work, but it exposes the trigger as a checkbox, which confuses users, so a button plus a few lines of JS is the better trade.
Should I use role=”menubar” for my website navigation?
Almost certainly not. Reserve menubar and menuitem for application style command menus. For a list of page links, the disclosure pattern shown above matches what people expect and keeps links announced as links.
Where should aria-expanded go, on the button or the panel?
On the button that the user activates. Putting it on the panel means it is never announced, because focus rarely lands on the panel itself.
Is aria-controls required?
It is not required and support varies between screen readers, but it is valid, it documents the relationship, and JAWS in particular can use it. Include it as long as the id it references exists.
How many levels of dropdown are acceptable?
Two at most. Every extra level multiplies the number of keystrokes and the chance of a focus bug. If your information architecture needs three levels, use a mega menu with real headings inside the panel, or move the deeper links onto a landing page. adasitecompliance.com makes the same point with more data.
Should a mobile menu trap focus?
Only if it visually covers the page like a dialog. In that case set inert on the rest of the page, trap focus inside the menu, close on Escape and return focus to the hamburger. If the menu simply pushes content down, no trap is needed.
Does an accessible navigation menu help SEO?
Indirectly, yes. Semantic markup, crawlable <a href> links instead of JavaScript click handlers, clear link text and a fast, non blocking script all make it easier for crawlers to discover and understand your site structure. Accessibility and technical SEO overlap heavily in the navigation layer.
Wrapping Up
The gap between the broken menu at the top of this article and the finished one is not framework magic. It is a handful of decisions: use the right element, expose the state, keep focus predictable, and test with the keyboard before you ship. Do that once, save it as a component, and every page on your site inherits it.
Ship the version above, run the seven step keyboard test, then listen to it with NVDA or VoiceOver. If it passes all three, your accessible navigation menu is already ahead of the majority of the web.

