How to Build an Accessible Navigation Menu with HTML, CSS and JavaScript

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)
website navigation menu

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

  1. No landmark. A <div class="nav"> is invisible to the landmark navigation feature of screen readers.
  2. No list. Without <ul>/<li>, users never hear “list of 4 items”, so they cannot judge the size of the menu.
  3. A div as a button. The hamburger is not focusable, does not respond to Enter or Space, and has no role or name.
  4. Icon with no accessible name. The <img> has no alt, so it is announced as the file name or nothing at all.
  5. Hover only dropdown. Keyboard and touch users can never open the submenu.
  6. Link used as a trigger. <a href="#"> promises navigation and delivers a toggle.
  7. No state exposed. Nothing tells assistive tech whether the menu or submenu is open or closed.
  8. Focus outline removed. outline: none with no replacement is a straight WCAG 2.4.7 failure.
  9. Hidden links stay in the tab order. display: none is fine here, but the moment someone uses opacity: 0 or visibility tricks, invisible links become tab traps.
  10. 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", not aria-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.

website navigation menu

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" and focusable="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-label where you can, and never leave the button unnamed.
  • aria-expanded is toggled in JavaScript. This is what makes a screen reader say “Menu, button, collapsed” then “expanded”.
  • aria-controls points 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.

website navigation menu

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 inert or aria-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:

  1. Targets of at least 44 by 44 CSS pixels satisfy WCAG 2.5.8 comfortably and help everyone on a phone.
  2. The dropdown panel needs an opaque background and a border, otherwise text overlaps page content in Windows High Contrast Mode.
  3. Honour prefers-reduced-motion. Sliding menus trigger nausea for users with vestibular disorders.
website navigation menu

How to Test Your Accessible Navigation Menu

Keyboard Test (2 Minutes, No Tools Needed)

  1. Put the mouse down. Press Tab from the very top of the page. The skip link should appear first.
  2. Tab into the nav. Can you see where focus is at every single step?
  3. Press Enter or Space on the dropdown trigger. Does the panel open?
  4. Press Arrow Down and Arrow Up inside the panel. Does focus cycle through the links?
  5. Press Escape. Does the panel close and focus return to the trigger?
  6. Shift+Tab backwards through the whole menu. Anything stuck? Anything invisible receiving focus?
  7. 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.