Accessible navigation and skip links

Navigation is one of the first things a screen reader or keyboard user encounters. A well-structured, accessible navigation system makes the difference between a usable site and an unusable one.

Keyboard users must tab through every link in the header and navigation before reaching the main content on every page load. A skip link solves this by allowing them to jump directly to the main content.

<!-- As the very first element inside <body> -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<header>
    <nav>
        <!-- Navigation links -->
    </nav>
</header>

<main id="main-content" tabindex="-1">
    <!-- Page content -->
</main>
.skip-link {
    position: absolute;
    top: -100%;
    left: 0;
    padding: 0.75rem 1.5rem;
    background: #005fcc;
    color: #ffffff;
    font-weight: 600;
    z-index: 9999;
    transition: top 0.2s ease;
}

.skip-link:focus {
    top: 0;
}

Why tabindex="-1" on <main> ? Without it, some browsers will scroll to the anchor but not move keyboard focus. Adding tabindex="-1" ensures focus actually moves to the main content area.

Accessible <nav> structure

Label multiple navigations

When a page has more than one <nav>, each must have a unique label :

<nav aria-label="Primary">
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/blog">Blog</a></li>
        <li><a href="/about">About</a></li>
    </ul>
</nav>

<nav aria-label="Footer">
    <ul>
        <li><a href="/privacy">Privacy Policy</a></li>
        <li><a href="/terms">Terms of Service</a></li>
    </ul>
</nav>

Indicate the current page

Use aria-current="page" to tell screen readers which link represents the currently active page :

<nav aria-label="Primary">
    <ul>
        <li><a href="/" aria-current="page">Home</a></li>
        <li><a href="/blog">Blog</a></li>
        <li><a href="/about">About</a></li>
    </ul>
</nav>

This is far more reliable than relying on visual styles like “active” classes, which provide no semantic meaning.

Breadcrumbs help users understand their position in the site hierarchy :

<nav aria-label="Breadcrumb">
    <ol>
        <li><a href="/">Home</a></li>
        <li><a href="/products">Products</a></li>
        <li><a href="/products/shoes" aria-current="page">Shoes</a></li>
    </ol>
</nav>
/* Visual separators using CSS — not announced by screen readers */
nav[aria-label="Breadcrumb"] ol {
    display: flex;
    list-style: none;
    padding: 0;
}

nav[aria-label="Breadcrumb"] li + li::before {
    content: '/';
    margin: 0 0.5rem;
    color: #666;
}

Note : Use <ol> (ordered list) for breadcrumbs since the order matters. The CSS-generated separator (/) is invisible to screen readers, avoiding noisy “slash” announcements.

Scenarios and Edge Cases

Mega menus

Large navigation menus (mega menus) require careful implementation :

  • Use aria-expanded on the trigger button to indicate menu state.
  • Add aria-haspopup="true" on the trigger to indicate a submenu exists.
  • Allow Escape to close the menu and return focus to the trigger.
  • Use arrow keys for navigation within the menu (following the WAI-ARIA Menu pattern).
<nav aria-label="Primary">
    <ul role="menubar">
        <li role="none">
            <button role="menuitem" aria-haspopup="true" aria-expanded="false">
                Products
            </button>
            <ul role="menu" hidden>
                <li role="none"><a role="menuitem" href="/shoes">Shoes</a></li>
                <li role="none"><a role="menuitem" href="/bags">Bags</a></li>
            </ul>
        </li>
    </ul>
</nav>

Mobile hamburger menus

  • The toggle button must use aria-expanded and aria-controls.
  • The hidden menu must have aria-hidden="true" when closed.
  • Focus must move into the menu when opened and return to the toggle button when closed.
<button
    aria-expanded="false"
    aria-controls="mobile-menu"
    aria-label="Open navigation menu"
>
    <span class="hamburger-icon" aria-hidden="true"></span>
</button>

<nav id="mobile-menu" aria-label="Mobile navigation" hidden>
    <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
    </ul>
</nav>

Single-page application (SPA) navigation

In SPAs, route changes don’t trigger a page reload, so screen readers have no way of knowing content has changed :

  • Announce the new page : Update document.title and announce it via an aria-live region.
  • Move focus : Focus the new page’s <h1> or <main> element.
  • Update aria-current : Reflect the new active route in the navigation.
function onRouteChange(newTitle) {
    document.title = newTitle;

    // Announce to screen readers
    const announcer = document.getElementById('route-announcer');
    announcer.textContent = newTitle;

    // Move focus
    const heading = document.querySelector('h1');
    heading.setAttribute('tabindex', '-1');
    heading.focus();
}
<div id="route-announcer" aria-live="assertive" class="sr-only"></div>

Pagination

Paginated lists should announce the current page and total within the navigation :

<nav aria-label="Pagination">
    <ul>
        <li><a href="/page/1">1</a></li>
        <li><a href="/page/2" aria-current="page">2</a></li>
        <li><a href="/page/3">3</a></li>
    </ul>
</nav>

Resources

If you found this helpful, share it with someone who's building for the web.

- Vinay Ranjan