Mark up a primary navigation as a labelled list

The problem

A site header holds the primary links, styled as a horizontal row with the bullets removed. Screen-reader users need to know that this cluster is the main navigation, that it is distinct from the footer and section navigations on the same page, and how many choices it contains. Current Chromium, Firefox and Safari with their bundled screen readers are assumed.

Short answer

Put the links in a ul inside a nav element, name the nav with aria-label or aria-labelledby, mark the active link with aria-current="page", and restate role="list" on the ul once list-style: none removes its markers.

A row of links in a header can be marked up three ways and only one of them tells an assistive technology what it is looking at. The landmark says which region this is, the list says how large the choice set is, and the current-state attribute says which of them the reader is already on.

Language: HTML
<header class="site-header">
  <a class="brand" href="/">DevExamples</a>
  <nav aria-label="Primary">
    <ul class="site-nav" role="list">
      <li><a href="/css/">CSS</a></li>
      <li><a href="/html/" aria-current="page">HTML</a></li>
      <li><a href="/javascript/">JavaScript</a></li>
      <li><a href="/search/">Search</a></li>
    </ul>
  </nav>
</header>

Language: CSS
.site-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.site-nav a[aria-current="page"] {
  border-block-end: 2px solid currentcolor;
  font-weight: 600;
}

Explanation

The nav element maps to the ARIA navigation role, so no role attribute is needed and adding one only risks a typo. What it cannot infer is intent: a page with a primary, a footer and an in-page table-of-contents navigation exposes three landmarks that all announce as “navigation”, and the landmark picker gives the reader no way to choose between them. aria-label="Primary" is the cheapest fix, and aria-labelledby pointing at a visible heading is better where one exists, because the name then updates when the heading does. Inside the region, the ul is what supplies scale. Screen readers report the role, the number of items and the position within them, and they offer list-specific commands to jump between items; a set of sibling anchors inside div elements reports none of that, even though it looks identical.

Removing the markers in CSS is where the semantics and the design collide. WebKit does not map a ul whose default marker display has been switched off with list-style: none to a list, and applying display: flex to the same element produces the same result, so the markup restates role="list" to recover it. The attribute is redundant in engines that kept the list and load-bearing in the one that did not, which makes it cheaper than testing all three on every change. aria-current="page" belongs on the anchor rather than the li, because it is the link that is activated and the link whose name the reader recites; the value page is the specific keyword, so the announcement says “current page” instead of a bare “current”. The visual cue in the second block is a colour-independent rule plus weight, so the same information reaches readers who cannot rely on hue.

Two boundaries matter. aria-current="page" is only correct on the link that represents the document you are viewing; a site search link or a parent category should not carry it just because it is styled as active, and a dropdown item that is expanded rather than navigated should use aria-expanded instead. When the list collapses into a disclosure on small screens, hide it with display: none or the hidden attribute rather than with opacity or visibility alone plus no focus handling, because the point of hiding it is to take the links out of the tab order; the button that reveals them then needs an accessible name that says what it operates, plus aria-expanded reflecting its state, or the region becomes invisible to the keyboard as well as to the eye.

Usage notes

  • Name every nav on the page or none of them needs a name; an unnamed second landmark is announced as just another navigation region.
  • aria-labelledby is the better choice when a visible heading already names the region, because the label and the heading cannot then drift apart.
  • Keep one anchor per item: a list item that carries a click handler instead of a link is not focusable and has no role to report.

Common mistakes

  • Replacing the ul with divs to avoid the list, then losing the item count and the list navigation commands with the bullets.
  • Marking the current page with colour or a class only, so the cue is invisible to anyone who cannot see it.
  • Nesting nav inside nav, which turns the landmark list into a set of regions inside regions.

Caveats

  • Collapsing the list at narrow widths with display: none removes the links from the tab order as well as the screen, which is correct, but the toggle button then needs aria-expanded and aria-controls.
  • The menu and menuitem roles describe an application menu with arrow-key traversal; using them for site navigation promises behaviour the markup does not deliver.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References