// 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.

native-html intermediate wcag-2.2

// 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

HTML
<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>
CSS
.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;
  }
}
JavaScript
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);
Why duplicate links in desktop and mobile? The desktop <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
Focus trapping vs. focus wrapping This navigation pattern does not trap focus inside the menu. Unlike a modal dialog, the mobile menu is not a separate context — it's part of the page flow. Instead, tabbing past the edges of the menu closes it and returns focus to the trigger. This provides a natural exit mechanism without requiring the user to remember to press Escape.

// 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's aria-expanded state 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-expanded state) 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.75rem with 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

Using a <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.
No 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.
No focus management on open or close If focus stays on the hamburger button after the menu opens, keyboard users must tab an unknown number of times to find the menu links. If focus is lost when the menu closes, users are dropped to the top of the page. Always move focus to the first link on open and back to the trigger on close.
Hiding the nav from screen readers when collapsed Some implementations add 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.
No Escape key to close Keyboard users expect Escape to dismiss overlays and expanded menus. Without it, the only way to close the menu is to Shift+Tab back to the button or click outside — less discoverable and less efficient than the standard Escape convention.
Menu links not in a list If the mobile menu links are standalone <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
The verdict Unlike tabs, responsive navigation gets most of its accessibility from native HTML. The <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.