Skip to content
Accessibility

Accessible Navigation Patterns

Menus, breadcrumbs, tabs, and skip links — navigation that works for all users.

stellae.design

9 min read

Introduction

Navigation is how users move through your application. For sighted users, it's visual — menus, tabs, breadcrumbs. For screen reader users, it's landmarks, headings, and link lists. For keyboard users, it's Tab, Enter, and Arrow keys. Good navigation works for all three simultaneously.

The most common navigation patterns — nav bars, dropdown menus, tab panels, breadcrumbs, and pagination — each have specific accessibility requirements defined in the WAI-ARIA Authoring Practices.

This guide covers implementing these patterns with proper semantics, keyboard support, and screen reader announcements.


Key Concepts

html
<header>
  <nav aria-label="Main">
    <ul>{/* Primary navigation */}</ul>
  </nav>
</header>
<main>
  <nav aria-label="Breadcrumb">
    <ol>{/* Breadcrumbs */}</ol>
  </nav>
  {/* Page content */}
</main>
<aside>
  <nav aria-label="Table of Contents">
    <ul>{/* Section links */}</ul>
  </nav>
</aside>
<footer>
  <nav aria-label="Footer">
    <ul>{/* Footer links */}</ul>
  </nav>
</footer>
typescript
function NavMenu({ items }) {
  const [openIndex, setOpenIndex] = useState<number | null>(null);

  return (
    <nav aria-label="Main">
      <ul role="menubar">
        {items.map((item, i) => (
          <li key={i} role="none">
            {item.children ? (
              <>
                <button role="menuitem" aria-haspopup="true" aria-expanded={openIndex === i}
                  onKeyDown={e => {
                    if (e.key === 'ArrowDown') { setOpenIndex(i); }
                    if (e.key === 'Escape') { setOpenIndex(null); }
                  }}
                  onClick={() => setOpenIndex(openIndex === i ? null : i)}>
                  {item.label}
                </button>
                {openIndex === i && (
                  <ul role="menu">
                    {item.children.map((child, j) => (
                      <li key={j} role="none">
                        <a role="menuitem" href={child.href}>{child.label}</a>
                      </li>
                    ))}
                  </ul>
                )}
              </>
            ) : (
              <a role="menuitem" href={item.href}>{item.label}</a>
            )}
          </li>
        ))}
      </ul>
    </nav>
  );
}

Practical Examples

1. Breadcrumbs

typescript
function Breadcrumbs({ items }: { items: { label: string; href?: string }[] }) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-2">
        {items.map((item, i) => (
          <li key={i} className="flex items-center gap-2">
            {i > 0 && <span aria-hidden="true">/</span>}
            {item.href ? (
              <a href={item.href} className="text-blue-600 hover:underline">{item.label}</a>
            ) : (
              <span aria-current="page" className="font-medium">{item.label}</span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

2. Mobile Navigation

typescript
function MobileNav() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button aria-expanded={open} aria-controls="mobile-menu"
        aria-label={open ? 'Close menu' : 'Open menu'}
        onClick={() => setOpen(!open)}>
        {open ? <XIcon /> : <MenuIcon />}
      </button>
      <nav id="mobile-menu" aria-label="Main" hidden={!open}>
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/about">About</a></li>
          <li><a href="/contact">Contact</a></li>
        </ul>
      </nav>
    </>
  );
}

3. Pagination

typescript
function Pagination({ current, total }: { current: number; total: number }) {
  return (
    <nav aria-label="Pagination">
      <ul className="flex gap-2">
        <li>
          <a href={`?page=${current - 1}`}
            aria-disabled={current === 1} className={current === 1 ? 'opacity-50' : ''}>
            Previous
          </a>
        </li>
        {Array.from({ length: total }, (_, i) => i + 1).map(page => (
          <li key={page}>
            <a href={`?page=${page}`}
              aria-current={page === current ? 'page' : undefined}
              className={page === current ? 'font-bold bg-blue-100 px-3 py-1 rounded' : 'px-3 py-1'}>
              {page}
            </a>
          </li>
        ))}
        <li>
          <a href={`?page=${current + 1}`}
            aria-disabled={current === total}>
            Next
          </a>
        </li>
      </ul>
    </nav>
  );
}

Best Practices

  • ✅ Use <nav> with aria-label when you have multiple navigation regions
  • ✅ Use aria-current='page' for the current page in navigation
  • ✅ Implement Arrow key navigation within menubars and tab lists
  • ✅ Use aria-expanded on buttons that toggle menus
  • ✅ Close menus on Escape key and return focus to the trigger
  • ❌ Don't use nested <a> tags — invalid HTML and confusing for screen readers
  • ❌ Don't make mega-menus that trap keyboard users

Common Pitfalls

  • Multiple <nav> elements without aria-label — screen readers list them all as 'navigation'
  • Hamburger menus that don't trap focus or announce their state
  • Breadcrumbs without aria-current on the current page
  • Tab panels where Arrow keys don't move between tabs — only Tab key works

Accessibilityaccessibility

Was this article helpful?