Navigation
Headers, mobile menus, dropdowns, breadcrumbs, tabs, pagination and sidebars — with the ARIA and keyboard behaviour each one needs.
Navigation is the component people use with the keyboard most and look at the least. Almost every failure here is a semantics failure, not a styling one — the right element, the right ARIA attribute, and a focus style you can actually see.
Semantics first#
A <nav> landmark lets assistive technology jump straight to it. A page usually has more than one, so name them:
<nav aria-label="Main">…</nav>
<nav aria-label="Breadcrumb">…</nav>
<nav aria-label="Pagination">…</nav>- The label is announced as "Main navigation", so do not include the word "navigation" in it.
- Wrap the links in a
<ul>. A screen reader then announces "list, 5 items", which is real orientation information. <nav>is for significant groups of links. A couple of links in a footer paragraph do not need one.
A responsive header#
The standard shape: brand on the left, links in the middle, actions on the right, and a toggle that only exists on small screens.
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between gap-4 px-4 py-3">
<a href="#" class="flex items-center gap-2 font-semibold text-gray-900">
<span class="grid size-7 place-items-center rounded-md bg-indigo-600 text-sm text-white">A</span>
Acme
</a>
<nav aria-label="Main" class="hidden sm:block">
<ul class="flex items-center gap-1">
<li><a href="#" aria-current="page" class="rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 aria-[current=page]:bg-gray-100 aria-[current=page]:text-gray-900">Product</a></li>
<li><a href="#" class="rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 aria-[current=page]:bg-gray-100 aria-[current=page]:text-gray-900">Docs</a></li>
<li><a href="#" class="rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 aria-[current=page]:bg-gray-100 aria-[current=page]:text-gray-900">Pricing</a></li>
</ul>
</nav>
<div class="hidden items-center gap-2 sm:flex">
<a href="#" class="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100">Sign in</a>
<a href="#" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Start free</a>
</div>
<button type="button" aria-expanded="false" aria-controls="demo-menu" class="rounded-md p-2 text-gray-700 hover:bg-gray-100 sm:hidden">
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="sr-only">Open menu</span>
</button>
</div>
</header>The pieces that matter:
| Class | Job |
|---|---|
hidden md:flex | The desktop links, absent from the DOM flow on mobile |
md:hidden | The toggle button, gone on desktop |
aria-expanded="false" | Tells assistive tech the menu's state — must be updated by your JS |
aria-controls="mobile-menu" | Points the toggle at the element it opens |
sr-only | Gives the icon-only button an accessible name |
The mobile menu#
hidden is a real display: none, so the collapsed menu is out of the tab order for free. Keep the state in one place — the hidden property and aria-expanded — and update both together.
<button type="button" data-menu-button aria-expanded="false" aria-controls="mobile-menu" class="md:hidden …">
<span class="sr-only">Open main menu</span>
</button>
<nav id="mobile-menu" aria-label="Main" hidden class="border-t border-gray-200 md:hidden">
<ul class="space-y-1 px-4 py-3">
<li><a href="#" class="block rounded-md px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-100">Product</a></li>
<li><a href="#" class="block rounded-md px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-100">Docs</a></li>
</ul>
</nav>const button = document.querySelector('[data-menu-button]')
const menu = document.getElementById('mobile-menu')
const setOpen = (open) => {
button.setAttribute('aria-expanded', String(open))
menu.hidden = !open
}
button.addEventListener('click', () => setOpen(button.getAttribute('aria-expanded') !== 'true'))
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && button.getAttribute('aria-expanded') === 'true') {
setOpen(false)
button.focus()
}
})For a menu that slides in over the page rather than pushing it down, use a drawer:
<!-- Panel: off-canvas until `data-open` is set on it -->
<nav id="drawer" aria-label="Main"
class="fixed inset-y-0 right-0 z-50 w-72 translate-x-full bg-white p-6 shadow-xl transition-transform duration-200 data-open:translate-x-0 motion-reduce:transition-none">
…
</nav>data-open:translate-x-0 is the shorthand data-attribute variant — your JS only has to add or remove the data-open attribute. A slide-over built on <dialog> gets focus containment and Escape handling from the browser instead; see Modals & Toasts.
Active links#
Mark the current page in the markup, then style from it. aria-current="page" is the accessible signal and the styling hook, so the two can never drift apart.
<a href="/docs" aria-current="page"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-600
hover:bg-gray-100 hover:text-gray-900
aria-[current=page]:bg-gray-100 aria-[current=page]:font-semibold aria-[current=page]:text-gray-900">
Docs
</a>aria-current accepts page, step, location, date, time and true — use page for navigation and step in a wizard. A data attribute works too, and is convenient when a framework already computes the state:
<a data-active class="data-active:bg-gray-100 data-active:text-gray-900" aria-current="page">Docs</a>Dropdown menus#
<details> gives you an open/close disclosure with zero JavaScript, and group-open: styles anything inside it from that state.
<div class="p-6">
<details class="group relative w-56">
<summary class="flex cursor-pointer list-none items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-900 marker:content-[''] hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Resources
<svg class="size-4 text-gray-500 transition group-open:rotate-180" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" d="m6 9 6 6 6-6"/></svg>
</summary>
<ul class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white text-sm shadow-lg">
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">Documentation</a></li>
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">API reference</a></li>
<li><a href="#" class="block px-3 py-2 hover:bg-indigo-50 hover:text-indigo-700">Changelog</a></li>
</ul>
</details>
</div>marker:content-[''] (plus list-none) removes the default disclosure triangle so you can draw your own chevron. v4.1 The details-content: variant targets ::details-content, the box around everything after the summary, which is what you animate for a smooth open in supporting browsers.
Hover-opened dropdowns are a common bug: they are unreachable by keyboard and impossible on touch. If you want hover, open on hover and on click/focus, and keep a generous close delay.
Breadcrumbs#
<div class="p-6">
<nav aria-label="Breadcrumb">
<ol class="flex flex-wrap items-center gap-1 text-sm text-gray-500">
<li><a href="#" class="rounded px-1 hover:text-gray-900 hover:underline">Docs</a></li>
<li aria-hidden="true" class="text-gray-300">/</li>
<li><a href="#" class="rounded px-1 hover:text-gray-900 hover:underline">Components</a></li>
<li aria-hidden="true" class="text-gray-300">/</li>
<li><span aria-current="page" class="px-1 font-medium text-gray-900">Navigation</span></li>
</ol>
</nav>
</div>The last crumb is the current page, so it is a <span>, not a link, and carries aria-current="page". Separators are decorative and get aria-hidden="true" — otherwise a screen reader reads "slash" between every level. An ordered list is correct here because the hierarchy has an order.
Tabs#
Tabs have a strict contract. Get the roles and the relationships right, or a screen reader announces three unrelated buttons.
<div class="p-6">
<div class="border-b border-gray-200">
<div role="tablist" aria-label="Account settings" class="-mb-px flex gap-6">
<button type="button" role="tab" id="tab-profile" aria-selected="true" aria-controls="panel-profile"
class="border-b-2 border-indigo-600 px-1 pb-3 text-sm font-medium text-indigo-600">
Profile
</button>
<button type="button" role="tab" id="tab-billing" aria-selected="false" aria-controls="panel-billing" tabindex="-1"
class="border-b-2 border-transparent px-1 pb-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700">
Billing
</button>
<button type="button" role="tab" id="tab-team" aria-selected="false" aria-controls="panel-team" tabindex="-1"
class="border-b-2 border-transparent px-1 pb-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700">
Team
</button>
</div>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile" tabindex="0" class="pt-4 text-sm text-gray-600">
Your name, avatar and public profile.
</div>
</div>role="tablist"on the container, with anaria-label.role="tab"on each control, witharia-selectedandaria-controls.- Roving tabindex. Only the selected tab is in the tab order; the others get
tabindex="-1"and are reached with the arrow keys. role="tabpanel"witharia-labelledbypointing back at its tab, andtabindex="0"so the panel itself is reachable.
The arrow-key handling is JavaScript — Left/Right (or Up/Down for a vertical tablist) move selection, Home and End jump to the ends. -mb-px on the tablist pulls the tabs down so their active border sits on top of the container's border rather than beside it.
Pagination#
<div class="p-6">
<nav aria-label="Pagination" class="flex items-center justify-between gap-4">
<span class="rounded-md px-3 py-2 text-sm font-medium text-gray-400">Previous</span>
<ul class="flex items-center gap-1">
<li><a href="#" aria-current="page" class="rounded-md px-3 py-2 text-sm font-medium tabular-nums text-gray-600 hover:bg-gray-100 aria-[current=page]:bg-indigo-600 aria-[current=page]:text-white">1</a></li>
<li><a href="#" class="rounded-md px-3 py-2 text-sm font-medium tabular-nums text-gray-600 hover:bg-gray-100">2</a></li>
<li><a href="#" class="rounded-md px-3 py-2 text-sm font-medium tabular-nums text-gray-600 hover:bg-gray-100">3</a></li>
<li aria-hidden="true" class="px-2 text-sm text-gray-400">…</li>
<li><a href="#" class="rounded-md px-3 py-2 text-sm font-medium tabular-nums text-gray-600 hover:bg-gray-100">12</a></li>
</ul>
<a href="#" class="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100">Next</a>
</nav>
</div>Render an unavailable Previous/Next as a <span>, not a disabled link — there is no disabled attribute on <a>, and pointer-events-none still leaves it focusable and clickable by keyboard. aria-current="page" marks the current page number, and tabular-nums stops the buttons resizing as the digits change.
Sidebar navigation#
<aside class="sticky top-16 h-[calc(100dvh_-_4rem)] w-64 shrink-0 overflow-y-auto border-r border-gray-200 py-6 pr-4">
<nav aria-label="Documentation">
<p id="grp-layout" class="px-3 text-xs font-semibold tracking-wide text-gray-500 uppercase">Layout</p>
<ul aria-labelledby="grp-layout" class="mt-2 space-y-0.5">
<li>
<a href="/layout/grid" aria-current="page"
class="block rounded-md border-l-2 border-transparent px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-900 aria-[current=page]:border-indigo-600 aria-[current=page]:bg-indigo-50 aria-[current=page]:font-medium aria-[current=page]:text-indigo-700">
Grid
</a>
</li>
</ul>
</nav>
</aside>sticky top-16 holds the sidebar below a 4rem header; the matching h-[calc(100dvh_-_4rem)] plus overflow-y-auto gives the sidebar its own scrollbar when the list is long. The underscores in that arbitrary value become spaces — calc() needs whitespace around its operators. dvh rather than vh accounts for mobile browser chrome that appears and disappears.
Sticky headers#
<header class="sticky top-0 z-40 border-b border-gray-200 bg-white/80 backdrop-blur-md supports-[backdrop-filter]:bg-white/60 dark:border-gray-800 dark:bg-gray-950/80">
…
</header>- The background needs some opacity for the blur to be visible, but not so much that text behind it shows through the header's own text.
supports-[backdrop-filter]:bg-white/60keeps a solid, readable fallback wherebackdrop-filteris unsupported and only lowers the opacity where the blur will actually happen.z-40puts the header above page content but below modals and toasts. See Overflow & Layering for a scale worth adopting.- Add
scroll-mt-16to headings so in-page anchors are not hidden underneath the header.
Skip links#
The first focusable element on the page should let a keyboard user jump past the navigation. It is invisible until it is focused.
<a href="#main"
class="sr-only rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-lg outline-2 outline-indigo-600
focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50">
Skip to content
</a>
…
<main id="main" tabindex="-1">…</main>sr-only hides it visually while keeping it focusable; focus:not-sr-only restores it on focus, and the positioning utilities place it over the header. tabindex="-1" on the target makes the jump actually move focus, not just scroll — without it, some browsers move the scroll position but leave focus behind.
Keyboard accessibility#
| Component | Expected keys |
|---|---|
| Links and buttons | Tab to reach, Enter (and Space for buttons) to activate |
| Disclosure / dropdown | Enter or Space to toggle, Esc to close, focus returns to the trigger |
Menu (role="menu") | Arrow keys to move, Esc to close, Home/End to jump |
| Tabs | Arrows to change tab, Tab to leave the tablist and enter the panel |
| Mobile drawer | Esc to close; focus must not reach the closed panel |
Everything focusable needs a visible focus style. focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 is a reasonable default for navigation, since focus-visible shows the ring for keyboard users without flashing it on every mouse click.
Common mistakes#
<div onclick>as a menu item. Not focusable, not announced, no keyboard activation.aria-expandedthat never changes. A staticfalseis worse than no attribute at all.- Hover-only dropdowns. Unusable on touch and by keyboard.
- Styling the active link without
aria-current. Sighted users see it; nobody else does. role="menu"on a list of navigation links. Menu semantics promise arrow-key navigation you have not built. Plain links in a list are correct.- An off-canvas panel hidden only with
translate-x-full. It stays in the tab order. - A missing skip link on a page with a long header.
overflow-hiddenon a header containing a dropdown. The menu is clipped at the header's edge.- Breadcrumb separators as real text. They get read out; mark them
aria-hidden="true".
Related topics#
- Buttons — the toggle and its focus styles
- Modals & Toasts — drawers, dialogs and the top layer
- Position —
sticky,fixedand their gotchas - Overflow & Layering — z-index scales and stacking contexts
- States & Variants —
aria-*anddata-*variants