Tailwind CSS Handbook v4.3

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:

HTML
<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.

Loading preview…

The pieces that matter:

ClassJob
hidden md:flexThe desktop links, absent from the DOM flow on mobile
md:hiddenThe 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-onlyGives 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.

HTML
<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>
menu.js
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:

HTML
<!-- 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.

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.

HTML
<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:

HTML
<a data-active class="data-active:bg-gray-100 data-active:text-gray-900" aria-current="page">Docs</a>

<details> gives you an open/close disclosure with zero JavaScript, and group-open: styles anything inside it from that state.

Loading preview…

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.

Loading preview…

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.

Loading preview…
  1. role="tablist" on the container, with an aria-label.
  2. role="tab" on each control, with aria-selected and aria-controls.
  3. Roving tabindex. Only the selected tab is in the tab order; the others get tabindex="-1" and are reached with the arrow keys.
  4. role="tabpanel" with aria-labelledby pointing back at its tab, and tabindex="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#

Loading preview…

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.

HTML
<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#

HTML
<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/60 keeps a solid, readable fallback where backdrop-filter is unsupported and only lowers the opacity where the blur will actually happen.
  • z-40 puts the header above page content but below modals and toasts. See Overflow & Layering for a scale worth adopting.
  • Add scroll-mt-16 to headings so in-page anchors are not hidden underneath the header.

The first focusable element on the page should let a keyboard user jump past the navigation. It is invisible until it is focused.

HTML
<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#

ComponentExpected keys
Links and buttonsTab to reach, Enter (and Space for buttons) to activate
Disclosure / dropdownEnter 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
TabsArrows to change tab, Tab to leave the tablist and enter the panel
Mobile drawerEsc 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-expanded that never changes. A static false is 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-hidden on 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".