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.
Skip links — bypassing repeated content
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. Addingtabindex="-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.
Breadcrumb navigation
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-expandedon the trigger button to indicate menu state. - Add
aria-haspopup="true"on the trigger to indicate a submenu exists. - Allow
Escapeto 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-expandedandaria-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.titleand announce it via anaria-liveregion. - 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.