Accessible Navigation Menus and Dropdowns That Work
Site navigation is a disclosure pattern, not an ARIA menu, and choosing it fixes most keyboard and screen reader bugs in one go.
The main navigation is the one component every site has, and the one a keyboard user meets first. When it fails, they cannot reach anything else. It is also the component where most of the ARIA advice you find online makes things worse, because it starts from the word "menu".
This guide covers the pattern W3C recommends for site navigation, the markup for it, what to do about hover submenus and mobile toggles, and how to explain the findings to whoever owns the site. It applies whether you write the markup yourself or are choosing a theme, a page builder block or a plugin.
Why role="menu" is usually the wrong choice for site navigation
In everyday speech a navigation bar is a "menu". In ARIA, role="menu" and role="menubar" mean something narrower: a widget that behaves like the menu bar at the top of a desktop application. The W3C ARIA Authoring Practices Guide (APG) defines a menu as "a widget that offers a list of choices to the user, such as a set of actions or functions" (APG, Menu and Menubar Pattern).
Adding those roles is a promise. Screen readers announce a menu and expect the keyboard model that comes with it: the arrow keys move between items, only one item sits in the Tab sequence, and Tab leaves the whole menubar. Most navigation bars that carry role="menubar" deliver none of that. They are a list of links with the roles pasted on, so the user is told one thing and gets another.
The APG's own navigation menubar example opens with a warning: "The menubar pattern requires complex functionality that is unnecessary for typical site navigation that is styled to look like a menubar with expandable sections or fly outs. A pattern more suited for typical site navigation with expandable groups of links is the Disclosure Pattern" (APG, Navigation Menubar Example).
So the menubar is not forbidden. It is a lot of work to get right, and for a list of pages it buys nothing. If your navigation is a set of links, some of them grouped under section headings, use the disclosure pattern below.
One line on a related guide: a custom select or autocomplete field is a combobox, a different widget with its own rules, covered in how to build accessible custom dropdowns and comboboxes. This guide is about site navigation only.
The disclosure pattern: a button and a list of links
The APG's disclosure navigation example "does not use the menu role because it does not provide the complex functionality that assistive technologies expect in a widget that has the menu role. Typical site navigation does not need all the keyboard interactions specified by the menu and menubar pattern" (APG, Example Disclosure Navigation Menu).
The structure is plain HTML. A nav landmark holds a list. A top-level item is either a link, or a button that shows and hides a nested list of links. The button carries two attributes: aria-expanded says whether its list is open, and aria-controls points to the list.
<!-- Broken: menu roles with no menu keyboard model, and a div as the trigger -->
<ul role="menubar">
<li role="menuitem">
<div class="nav-trigger">Services</div>
<ul role="menu" class="submenu">
<li role="menuitem"><a href="/services/audits">Audits</a></li>
</ul>
</li>
</ul>
<!-- Fixed: a nav landmark, a real button, and links in a list -->
<nav aria-label="Main">
<ul>
<li><a href="/">Home</a></li>
<li>
<button type="button" aria-expanded="false" aria-controls="nav-services">Services</button>
<ul id="nav-services" hidden>
<li><a href="/services/audits">Audits</a></li>
<li><a href="/services/fixes">Fixes</a></li>
</ul>
</li>
</ul>
</nav>
The script only has to flip two things when the button is pressed:
// Toggle the submenu and keep aria-expanded in step with what is on screen
document.querySelectorAll('nav button[aria-controls]').forEach((button) => {
const list = document.getElementById(button.getAttribute('aria-controls'));
button.addEventListener('click', () => {
const open = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!open));
list.hidden = open;
});
});
Because it is a real button, Enter and Space work and it is announced as a button with its state, which is what 4.1.2 Name, Role, Value (Level A) asks for: the name and role "can be programmatically determined", and for its state, "notification of changes to these items is available to user agents, including assistive technologies" (WCAG 2.2). Our guide to ARIA attributes explains why the native element beats a div with roles added.
The keyboard behaviour to build
From the APG disclosure example, the required keys are few:
- Tab and Shift + Tab move between the top-level links and buttons, and through the links of an open list.
- Enter or Space on a button opens or closes its list.
- Escape, when a list is open, closes it and puts focus back on its button.
Arrow keys, Home and End are marked optional in that example. Add them if you like, but Tab, Enter, Space and Escape are what 2.1.1 Keyboard (Level A) depends on: "All functionality of the content is operable through a keyboard interface" (WCAG 2.2). The general rules for focus and tab order are in our guide to keyboard navigation and focus management.
Should the section name be a link too?
A common design has "Services" as both a page and a submenu trigger. One element cannot be both: a link goes somewhere on Enter, a button opens the list. Either make the top-level item a link and give the submenu a separate small button beside it (with an accessible name such as "Services submenu"), or keep the button and put an "All services" link as the first item in the list. The second is simpler and works the same on touch screens.
Hover submenus and what 1.4.13 asks for
Many sites open submenus on mouse hover. You can keep that, as long as keyboard users have the button and the hover behaviour passes 1.4.13 Content on Hover or Focus (Level AA). W3C lists "sub-menus" among the content it covers (Understanding SC 1.4.13). Content that appears on hover or focus must be:
- Dismissible: "A mechanism is available to dismiss the additional content without moving pointer hover or keyboard focus". In practice, Escape closes the open submenu.
- Hoverable: "the pointer can be moved over the additional content without the additional content disappearing". A gap between the top item and the submenu that closes it as the pointer crosses fails this.
- Persistent: it "remains visible until the hover or focus trigger is removed, the user dismisses it, or its information is no longer valid". No timer that hides it after two seconds.
/* Broken: a 12px gap closes the submenu before the pointer reaches it */
.nav li { position: relative; }
.nav .submenu { position: absolute; top: calc(100% + 12px); }
/* Fixed: the submenu touches its trigger; the gap is padding inside it */
.nav .submenu { position: absolute; top: 100%; padding-top: 12px; }
Never make hover the only way in. A submenu that opens on hover and has no button is unreachable by keyboard and on many touch screens, which is a 2.1.1 failure before 1.4.13 even comes up.
The mobile toggle
On small screens the whole navigation usually hides behind a single toggle. It is the same disclosure pattern one level up, and it fails in the same few ways.
<!-- Broken: an icon in a div, no name, no state -->
<div class="hamburger" onclick="toggleNav()"><svg>...</svg></div>
<!-- Fixed: a named button that reports whether the navigation is open -->
<button type="button" class="nav-toggle" aria-expanded="false" aria-controls="site-nav">
<svg aria-hidden="true" focusable="false">...</svg>
<span class="visually-hidden">Menu</span>
</button>
Then check what happens around it:
- Focus order. After the toggle opens the navigation, the next Tab should land on the first link inside it. If the panel sits at the end of the markup, focus wanders through the whole page first.
- Escape. It should close the panel and return focus to the toggle.
-
A full-screen panel. If it covers the page, the content underneath must not take focus while it is open, or keyboard users tab into links they cannot see. The HTML
inertattribute on the main content does this. A panel that covers the page like this behaves as a dialog, so the focus rules in our guide to accessible modals apply. - A sticky header. When focus moves down the page, the sticky bar must not cover the focused element entirely. That is 2.4.11 Focus Not Obscured (Minimum), Level AA (Understanding SC 2.4.11).
Marking the current page with aria-current
Sighted users see the current page highlighted in the navigation. Screen reader users need the same signal in the markup. ARIA's aria-current "indicates the element that represents the current item within a container or set of related elements", and "Authors SHOULD only mark one element in a set of elements as current" (WAI-ARIA 1.2).
<!-- Broken: the highlight is only a class, so nothing is announced -->
<a href="/services/audits" class="active">Audits</a>
<!-- Fixed: announced as the current page, and styled from the same attribute -->
<a href="/services/audits" aria-current="page">Audits</a>
Style it with [aria-current="page"] rather than a separate class, so the visual highlight and the announced state can never disagree. Most CMSes and themes print an "active" class already; adding the attribute in the same template is usually one line.
More than one navigation on a page
Header, footer and breadcrumb navigation are all nav landmarks. The APG says: "If a page includes more than one navigation landmark, each should have a unique label" (APG, Navigation Landmark). aria-label="Main", aria-label="Footer" and aria-label="Breadcrumb" are enough. Leave the word "navigation" out of the label, because screen readers already announce the landmark type.
Link text matters in navigation too: icon-only social links and repeated "More" links are common findings. The link text checker flags links with no name or vague text.
What a scan finds and what needs a person
An automated scan can find a lot of this in the rendered page: invalid ARIA such as menu roles missing their required children, buttons and links with no accessible name, and divs with click handlers that a keyboard cannot reach. Our ARIA checker and keyboard and focus checker look for those, and a free accessibility audit runs them on any page.
A scan cannot tell you whether the submenu stays open while the pointer crosses to it, whether Escape returns focus to the button, or whether the mobile panel lets focus escape underneath it. 1.4.13 in particular is a manual check. No automated tool can confirm a navigation conforms to WCAG. Our step by step manual keyboard accessibility test covers the rest in about twenty minutes.
How to explain it to a client or manager
Keep it concrete: "Keyboard users cannot open the Services menu, so they cannot reach the six pages under it." That is a sentence a manager can act on. The fix for most navigation findings is small, a button instead of a div, two attributes and an Escape handler, and it usually lives in one template, so it fixes every page at once.
Your checklist
- The navigation is a
navwith a list of links, and nomenu,menubarormenuitemroles unless you built the full menubar keyboard model. - Every submenu trigger is a
buttonwitharia-expandedthat changes when it opens and closes. - Enter and Space open a submenu; Escape closes it and returns focus to its button.
- Hover submenus also open from the button, stay open while the pointer moves onto them and have no auto-hide timer.
- The mobile toggle is a named button with
aria-expanded, focus moves into the panel, and nothing behind a full-screen panel takes focus. - The sticky header never fully covers the focused element.
- The current page link has
aria-current="page", and the highlight is styled from it. - Each
navon the page has a unique label. - Run a free scan for the countable problems, then test the navigation by keyboard, on a phone and with the pointer.