Start with the essentials
A solid main navigation can be built from just two ingredients: <a> elements and a small amount of CSS to lay them out. That minimal setup works for anyone — mouse, keyboard, touch, or screen reader — and provides a good baseline to build on.
From there, enhancements can be added progressively. If any layer fails, the navigation still works because it falls back to a simpler layer underneath. The goal is to reach a point where the navigation is neither too bare nor overloaded with complexity.
Mark the current page
The most useful first enhancement is highlighting the active page. A class like active-page on the current link communicates this visually, but only visually. Screen reader users get no signal about which link points to the current page.
The ARIA attribute aria-current="page" solves that. According to the WAI-ARIA specification, aria-current is a state that indicates the element representing the current item within a container or set of related elements. When applied to a link, a screen reader announces something along the lines of "current page, link, About Us" instead of only "link, About Us".
An added benefit: aria-current can be used as a CSS selector, making a separate class unnecessary for styling the active link.
Reveal the item count
Sighted users can glance at a navigation and instantly know how many links it holds. Screen reader users don't get that information for free; they may need to tab through every single link to discover the scope. That's a minor inconvenience for four links, but a real hurdle when a navigation contains dozens of items.
Wrapping each link in a list item and putting those in an unordered list changes this. A screen reader announces something like "list, 4 items" when a user encounters the list. From there, screen reader users can:
- Know the total number of items before interacting with them.
- Jump between list items using shortcuts.
- Move from list to list quickly.
- Hear the index of the current item, such as "list item, two of four."
Even if CSS fails to load, the list renders as a coherent group instead of unrelated links stacked together.
One caveat: VoiceOver in Safari drops list semantics when list-style: none is applied, because WebKit removes semantics for lists that don't look like lists. Other screen readers, including NVDA and VoiceOver in Chrome or Firefox, still announce the item count. Should Safari's behavior matter for your setup, adding an explicit role="list" to the <ul> restores the semantics without changing the visual appearance.
Add a landmark
With lists in place, the navigation is a well-structured list — but nothing yet marks it as the main navigation. Wrapping the <ul> in a <nav> element solves this. The <nav> element makes a screen reader announce "navigation," and it introduces a landmark on the page.
Landmarks are special regions such as <header>, <main>, and <footer> that screen readers can jump to directly. In NVDA, pressing the D key moves between landmarks. In VoiceOver, the rotor (activated with VO + U) lists all landmarks on the page. A typical page shows a small set: banner (the <header>), navigation (the <nav>), main, and content information (the <footer>). Landmarks should be reserved for critical UI sections only, such as the site search or pagination, to keep that list short.
Pages commonly contain multiple <nav> elements — for site-wide navigation, a local navigation, and pagination. Without labels, those navigation landmarks are indistinguishable. Labeling each one solves this:
- Use
aria-labelwhen the label text doesn't already appear on the page. - Use
aria-labelledbywith theidof an existing heading or other element when a visible label is already present.
Keep labels concise. Words like "navigation" or "menu" are redundant because the screen reader already provides that context.
Making the navigation work on small screens
When a long link list won't fit on narrow viewports, the practical pattern is to collapse the list behind a button labeled "Menu" or a burger icon. That requires coordinating a few accessibility concerns beyond basic show/hide logic:
- The hidden state must be communicated properly to assistive technology.
- The navigation has to be fully operable from the keyboard.
- The open/closed state has to be exposed to screen readers.
Adding the toggle button
Following progressive enhancement, the navigation should still make sense without JavaScript. The button is created in a <template> element in the HTML, then cloned and inserted into the navigation via JavaScript. Placing the button first in the DOM matters: when a keyboard user tabs away from it, focus should land on the first item of the list, which only happens if the button precedes the list.
<nav id="mainnav">
...
</nav>
<template id="burger-template">
<button type="button" aria-expanded="false" aria-label="Menu" aria-controls="mainnav">
<svg width="24" height="24" aria-hidden="true">
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z">
</svg>
</button>
</template>
aria-expandedtells screen readers whether the controlled region is currently open.aria-labelsupplies the accessible name for the icon-only button.aria-hiddenremoves the SVG itself from the accessibility tree, since its label comes fromaria-label.aria-controlsidentifies which element the button governs; support varies (JAWS reads it, for example).
The click handler toggles the state and also closes the panel on Escape. Use insertBefore, not appendChild, so the button ends up as the first child of the <nav>.
const nav = document.querySelector('#mainnav')
const list = nav.querySelector('ul');
const burgerClone = document.querySelector('#burger-template').content.cloneNode(true);
const button = burgerClone.querySelector('button');
// Toggle aria-expanded attribute
button.addEventListener('click', e => {
// aria-expanded="true" signals that the menu is currently open
const isOpen = button.getAttribute('aria-expanded') === "true"
button.setAttribute('aria-expanded', !isOpen);
});
// Hide list on keydown Escape
nav.addEventListener('keyup', e => {
if (e.code === 'Escape') {
button.setAttribute('aria-expanded', false);
}
});
// Add the button to the page
nav.insertBefore(burgerClone, list);
Styling resets the button to a bare icon that shows only on small viewports.
@media (min-width: 48em) {
nav {
--nav-button-display: none;
}
}
/* Reset button styling */
button {
all: unset;
display: var(--nav-button-display, flex);
}
Collapsing the list
Before hiding anything, fix the base layout. The <nav> is pulled out of the document flow and pinned to the top end corner of the viewport.
@media (min-width: 48em) {
nav {
--nav-button-display: none;
--nav-position: static;
}
}
nav {
position: var(--nav-position, fixed);
inset-block-start: 1rem;
inset-inline-end: 1rem;
}
A custom property, --nav-list-layout, switches between a column layout on narrow screens and a row layout on wide ones.
@media (min-width: 48em) {
nav {
--nav-button-display: none;
--nav-position: static;
}
ul {
--nav-list-layout: row;
}
}
ul {
display: flex;
flex-direction: var(--nav-list-layout, column);
flex-wrap: wrap;
gap: 1rem;
list-style: none;
margin: 0;
padding: 0;
}
The list itself is moved to the top end, stretched to full viewport height, and given a background and shadow so it reads as a panel rather than a plain list.
@media (min-width: 48em) {
nav {
--nav-button-display: none;
--nav-position: static;
}
ul {
--nav-list-layout: row;
--nav-list-position: static;
--nav-list-padding: 0;
--nav-list-height: auto;
--nav-list-width: 100%;
--nav-list-shadow: none;
}
}
ul {
background: rgb(255, 255, 255);
box-shadow: var(--nav-list-shadow, -5px 0 11px 0 rgb(0 0 0 / 0.2));
display: flex;
flex-direction: var(--nav-list-layout, column);
flex-wrap: wrap;
gap: 1rem;
height: var(--nav-list-height, 100vh);
list-style: none;
margin: 0;
padding: var(--nav-list-padding, 2rem);
position: var(--nav-list-position, fixed);
inset-block-start: 0; /* Logical property. Equivalent to top: 0; */
inset-inline-end: 0; /* Logical property. Equivalent to right: 0; */
width: var(--nav-list-width, min(22rem, 100vw));
}
button {
all: unset;
display: var(--nav-button-display, flex);
position: relative;
z-index: 1;
}
Critically, hide only the list, not the whole <nav>—the latter is an important landmark and must stay available. The aria-expanded value toggled earlier can drive the CSS condition directly:
@media (min-width: 48em) {
ul {
--nav-list-visibility: visible;
}
}
ul {
visibility: var(--nav-list-visibility, visible);
}
/* Hide the list on narrow viewports, if it comes after an element with
aria-expanded set to "false". */
[aria-expanded="false"] + ul {
visibility: var(--nav-list-visibility, hidden);
}
Use visibility: hidden or display: none to collapse the panel. Properties like opacity: 0 or translateX(100%) only remove visual presence; links would remain keyboard-focusable while invisible, a disorienting combination. Visibility and display properties remove the content from both visual and keyboard access.
Animating the collapse
One reason to favor visibility: hidden over display: none is animatability: visibility toggles between hidden and visible and can be combined with transform or opacity for slide or fade effects—display is not animatable.
The following transitions add a simple fade.
ul {
transition: opacity 0.6s linear, visibility 0.3s linear;
visibility: var(--nav-list-visibility, visible);
}
[aria-expanded="false"] + ul {
opacity: 0;
visibility: var(--nav-list-visibility, hidden);
}
If the animation involves motion, wrap the transition in a prefers-reduced-motion media query. Motion effects can trigger nausea, dizziness, and headaches, so limit them to users who haven't expressed a preference for reduced motion.
ul {
visibility: var(--nav-list-visibility, visible);
}
@media (prefers-reduced-motion: no-preference) {
ul {
transition: transform 0.6s cubic-bezier(.68,-0.55,.27,1.55), visibility 0.3s linear;
}
}
[aria-expanded="false"] + ul {
transform: var(--nav-list-transform, translateX(100%));
visibility: var(--nav-list-visibility, hidden);
}
Focus styles that work
Keyboard users depend on visible focus indicators to orient themselves. Removing outline entirely is worse than keeping browser defaults, but custom styles can be more visible and more on-brand.
Chrome's default focus style as of version 103:
Prefer :focus-visible over :focus for the custom treatment. The former lets the browser decide when to show the indicator—typically only for keyboard interaction—while :focus applies it indiscriminately to mouse and touch users who don't need the extra cue.
/* Remove the default :focus outline */
*:focus {
outline: none;
}
/* Show a custom outline on :focus-visible */
*:focus-visible {
outline: 2px solid var(--color-shades-dark);
outline-offset: 4px;
}
Browser support
:focus-visible is supported in Chrome and Edge from 86, Firefox from 85, and Safari from 15.4.
For the focus style itself, outline is the safest choice: it doesn't cause layout shifts the way border can, and it honors Windows high contrast mode. Avoid background-color or box-shadow, which custom contrast settings may not render at all.
The navigation is now progressively enhanced, semantic, accessible, and mobile-friendly. Further refinements could include trapping focus within the panel or making the rest of the page inert on narrow screens, plus a skip link for keyboard users. But guard against over-engineering—the goal is a solution that's neither too simple nor too complicated.
Navigation roles versus menu roles
Navigations and menus serve different purposes. A navigation groups links to related documents; a menu groups actions to be performed. When components mix both—a nav containing a modal-opening button, say—resist the urge to blend ARIA roles. Identify the component's primary job and mark it up accordingly.
The <nav> element maps to the implicit ARIA navigation role, which is sufficient. Sites nonetheless frequently add menu, menubar, or menuitem roles on top, assuming extra roles help screen reader users. The spec definitions suggest otherwise.
The navigation role
A collection of navigational elements (usually links) for navigating the document or related documents.
navigation (role), WAI-ARIA 1.1
The menu role
A menu is often a list of common actions or functions that the user can invoke. The menu role is appropriate when a list of menu items is presented in a manner similar to a menu on a desktop application.
menu (role), WAI-ARIA 1.1
The menubar role
A presentation of menu that usually remains visible and is usually presented horizontally. The
menubar (role), WAI-ARIA 1.1menubarrole is used to create a menu bar similar to those found in Windows, Mac, and Gnome desktop applications. A menu bar is used to create a consistent set of frequently used commands. Authors should ensure that menubar interaction is similar to the typical menu bar interaction in a desktop graphical user interface.
The menuitem role
An option in a set of choices contained by a menu or menubar.
menuitem (role), WAI-ARIA 1.1
Where menus actually belong
The specification is unambiguous: navigation is for document navigation, menu for action lists like in desktop applications. Unless you're building something comparable to Google Docs, the nav element with links is sufficient, and that includes SPAs and web apps.
Menus become appropriate when a row or item hosts multiple actions. A common pattern is a button per row that surfaces a list of possible operations:
<ul>
<li>
Product 1
<button aria-expanded="false" aria-controls="options1">Edit</button>
<div role="menu" id="options1">
<button role="menuitem">
Duplicate
</button>
<button role="menuitem">
Delete
</button>
<button role="menuitem">
Disable
</button>
</div>
</li>
<li>
Product 2
...
</li>
</ul>
Cost of misusing menu roles
Applying menu roles wrongly carries real risks. ARIA menus expect a rigid DOM shape—menuitem elements must be direct children of a menu. Deviating from that can break the semantics:
<!-- Wrong, don't do this -->
<ul role="menu">
<li>
<a href="#" role="menuitem">Item 1</a>
</li>
</ul>
Menu and menubar users also anticipate particular keyboard behavior from the ARIA Authoring Practices Guide:
- Enter and Space to activate an item.
- Arrow keys in all directions to move between items.
- Home and End for first and last items.
- Type-ahead (a–z) to jump to an item whose label starts with that character.
- Esc to close the menu.
Screen readers may automatically switch into an application browsing mode when they encounter a menu role, and users unfamiliar with the requisite shortcuts may be inadvertently locked out. Keyboard users might similarly expect Shift plus Tab navigation or other conventions that need to be hand-built. Getting menus right involves substantial custom interaction code; for a typical site navigation, a plain nav with links avoids the entire class of problems.
Going Further With Navigation
Building a robust, accessible main navigation is a core frontend task, but getting the details right often requires going beyond the basics. The following resources offer deeper technical guidance on common pitfalls and best practices for interactive elements.
Lists, Safari, and Screen Readers
Scott O'hara's Fixing Lists is a valuable reference for understanding how Safari handles list semantics. This is crucial, as even a well-structured <nav> with <ul> and <li> elements can behave differently across browsers, affecting how screen readers announce the structure.
ARIA Roles Beyond the Basics
It's tempting to add role="menu" for navigation items, but this often does more harm than good. Adrian Roselli's Don't Use ARIA Menu Roles for Site Nav explains why these roles are intended for application-like widgets, not site navigation. Implementing them correctly requires supporting complex keyboard interactions like arrow keys, which adds unnecessary complexity and can break the native HTML semantics users and assistive tech already understand.
For a detailed look at when interactive menu roles are appropriate, Heydon Pickering's Menus & Menu Buttons covers the design patterns for these more complex components. Similarly, Marco Zehe's article, WAI-ARIA menus, and why you should handle them with great care, provides further context on the dangers of over-applying these roles.
Focus and Visibility
Accessibility isn't just about the correct semantic markup; it also involves proper visual feedback. Kitty Giraudel's Hiding content responsibly is a great resource for ensuring that content you intend to hide (e.g., off-screen menus) is done so without removing it from the accessibility tree entirely.
Finally, managing focus styles is critical for keyboard users. Matthias Ott's piece on :focus-visible Is Here details how the :focus-visible pseudo-class allows you to style focus indicators only when needed (e.g., via keyboard), while avoiding unsightly outlines for mouse users.



