Accessible Navigation & Landmark Structure
A screen reader user rarely reads a page top to bottom. They jump — by landmark, by heading, by link — and the speed of that jump depends entirely on structure you either shipped or didn't. In a component framework it is easy to lose that structure: <Header />, <Sidebar /> and <Content /> all compile down to anonymous <div> elements unless someone deliberately chose otherwise. This guide covers the region skeleton every app needs, how to name repeated navigations, how to make skip links survive client-side routing, and the keyboard model a navigation menu owes its users.
It sits under Core Accessibility Principles for Modern Frameworks and pairs closely with Semantic HTML vs ARIA in Component Trees, which explains why native elements beat role attributes in the first place.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships2.4.1 Bypass Blocks2.4.3 Focus Order2.4.8 Location4.1.2 Name, Role, Value
Landmarks Are the Page's Table of Contents
Every screen reader exposes a landmark list: VoiceOver's rotor, NVDA's D key, JAWS's R key. That list is generated from a handful of native elements, and it is the fastest way to move around an unfamiliar page. When a framework app renders everything as <div>, the list is empty and the user is left arrowing through hundreds of nodes.
Implementation Guidelines:
- Map each shell component to the native element that carries its role:
<header>→ banner,<nav>→ navigation,<main>→ main,<aside>→ complementary,<footer>→ contentinfo. - Ship exactly one
<main>per rendered page. Two main regions is a1.3.1violation and makes the "jump to main" shortcut ambiguous. - Put every piece of content inside some landmark. Content that sits between regions is unreachable by landmark navigation and easy to miss.
- Do not add
role="banner"orrole="main"to a native element that already implies it — redundant roles are noise, not insurance.
// app/layout.tsx — the shell decides the landmark structure for every route.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<a href="#main-content" className="skip-link">Skip to main content</a>
<header>
<SiteLogo />
<nav aria-label="Primary">
<PrimaryLinks />
</nav>
</header>
{/* tabIndex={-1} makes the region programmatically focusable for the skip link */}
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>
<SiteMeta />
</footer>
</body>
</html>
);
}
The <header> and <footer> elements only map to banner and contentinfo when they are not nested inside <article>, <section>, <main> or <aside>. A <footer> inside an <article> is scoped to that article and disappears from the landmark list — which is correct behaviour, but surprising if you moved the element during a refactor and expected the landmark to follow.
Naming Repeated Landmarks
A page with three <nav> elements produces three rotor entries all reading "navigation". The user cannot tell the site navigation from the breadcrumb trail from the in-page table of contents without entering each one. Names fix that in one attribute.
Implementation Guidelines:
- Give every
<nav>anaria-labelwhen more than one exists on the page. One nav can go unlabelled, but labelling all of them is more predictable. - Do not include the word "navigation" in the label. The role is already announced, so
aria-label="Primary navigation"is spoken as "primary navigation navigation". - Prefer
aria-labelledbypointing at a visible heading when the region already has one — a visible name benefits everyone, not just screen reader users. - Apply the same rule to multiple
<aside>or<section>regions: a<section>only becomes a landmark because it has an accessible name.
// A breadcrumb trail: labelled nav + an ordered list + aria-current on the last item.
export function BreadcrumbTrail({ crumbs }: { crumbs: Crumb[] }) {
return (
<nav aria-label="Breadcrumb">
<ol>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;
return (
<li key={crumb.href}>
{isLast
// A11y rationale: 2.4.8 Location — aria-current="page" marks where
// the user is without relying on styling alone.
? <span aria-current="page">{crumb.label}</span>
: <a href={crumb.href}>{crumb.label}</a>}
</li>
);
})}
</ol>
</nav>
);
}
Naming interacts with the heading outline: if a region has a visible heading, aria-labelledby reuses it and the two stay in sync automatically. Hard-coded aria-label strings drift the moment someone edits the visible text — a recurring source of stale names in long-lived design systems.
Skip Links That Survive Client-Side Routing
2.4.1 Bypass Blocks asks for a way past repeated content. The classic answer is a skip link, and the classic bug is a skip link that moves the browser's scroll position but not the keyboard focus — so the next Tab lands back at the top of the navigation the user just skipped.
Implementation Guidelines:
- Add
tabIndex={-1}to the skip target so it can receive programmatic focus without joining the tab order. - Keep the link visually hidden until focused, never
display: none— a hidden-by-display link is removed from the tab order entirely. - Make the link the first focusable element in the DOM, ahead of the logo and any cookie banner.
- After a client-side route change, confirm the skip link still points at a target that exists in the new view. Framework-specific handling lives in implementing skip links in the Next.js App Router.
/* Visible on focus only — still in the tab order the rest of the time. */
.skip-link {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
left: 1rem;
top: 1rem;
width: auto;
height: auto;
padding: 0.75rem 1rem;
z-index: 1000;
}
Marking the Current Page
Sighted users read the current page from a highlighted link. Screen reader users get nothing from background-color alone, so aria-current carries that state into the accessibility tree.
Implementation Guidelines:
- Use
aria-current="page"for the exact current URL andaria-current="true"for a section that merely contains it. - Set the attribute on the link itself, not on its wrapper
<li>. - Never render the current page as a link to itself and omit
aria-current— the user cannot tell they are already there. - Drive the state from the router, so it stays correct after every client-side navigation.
'use client';
import { usePathname } from 'next/navigation';
export function PrimaryLinks({ links }: { links: Link[] }) {
const pathname = usePathname();
return (
<ul>
{links.map((link) => {
const isCurrent = pathname === link.href;
const isAncestor = !isCurrent && pathname.startsWith(link.href);
return (
<li key={link.href}>
<a
href={link.href}
// A11y rationale: 2.4.8 Location — "page" for the exact match,
// "true" for the section that contains it, nothing otherwise.
aria-current={isCurrent ? 'page' : isAncestor ? 'true' : undefined}
>
{link.label}
</a>
</li>
);
})}
</ul>
);
}
A Navigation Is Not a Menu
role="menu" describes an application menu — the File/Edit/View kind — with arrow-key navigation, typeahead, and no Tab movement between items. A site navigation made of links is not that. Applying menu roles to a list of links breaks two things at once: it hides the links from the links list, and it promises arrow-key behaviour the component almost certainly does not implement.
Implementation Guidelines:
- Default to a disclosure pattern for dropdown navigation: a
<button aria-expanded>that shows or hides a plain<ul>of links. - Reserve
role="menu"for lists of actions in application UI, and then implement the whole keyboard contract — arrows, Home/End, typeahead, Escape. - Keep the trigger a real
<button>; a<div onClick>is not keyboard-operable and not announced as a control. - Close the panel on Escape and return focus to the trigger — the same restoration contract described in Keyboard Navigation Patterns for Modals.
'use client';
import { useRef, useState } from 'react';
export function NavDisclosure({ label, children }: DisclosureProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<div
onKeyDown={(event) => {
if (event.key !== 'Escape' || !open) return;
setOpen(false);
// A11y rationale: 2.4.3 Focus Order — closing must return focus to the
// control that opened the panel, not drop it on the document body.
triggerRef.current?.focus();
}}
>
<button
ref={triggerRef}
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
{label}
</button>
{open && <ul>{children}</ul>}
</div>
);
}
The disclosure pattern is deliberately boring: links stay links, Tab still moves between them, and the only state you own is a boolean. That is why it survives refactors better than a hand-rolled menu, and why the dropdown guide treats full menu semantics as a deliberate, costly upgrade rather than a default.
Keeping the Heading Outline Navigable
Landmarks get a user to a region; headings get them around inside it. Screen readers list headings in source order with their levels, so a page that jumps from h2 to h4, or that uses heading tags for visual size, produces an outline that reads like a corrupted table of contents.
Implementation Guidelines:
- One
h1per page, matching the page's subject and — ideally — its<title>. - Never skip levels going down. Going back up (from
h4toh2) is fine and expected at the end of a subsection. - Choose the level from position in the document, and the size from CSS. A component that accepts a
levelprop keeps both honest. - Check the outline after composition: a
<Card>that hard-codes<h3>produces a broken outline the moment someone drops it under anh4.
// A heading whose level is a prop, so the same card works at any depth.
type HeadingProps = { level: 2 | 3 | 4 | 5 | 6; children: React.ReactNode };
export function Heading({ level, children }: HeadingProps) {
const Tag = `h${level}` as const;
return <Tag>{children}</Tag>;
}
In-Page Anchors and the Table of Contents
Long documentation pages usually ship a third navigation: an on-this-page list of fragment links. It has the same failure mode as the skip link — the browser scrolls, focus does not move — plus one of its own: sticky headers that cover the heading the user just jumped to.
Implementation Guidelines:
- Give every linked heading a stable
id. Auto-generated slugs must survive edits, or existing deep links rot silently. - Add
tabindex="-1"to headings that are fragment targets so activating the link moves focus, not just the viewport. - Set
scroll-margin-topto the height of any sticky header. Without it the target lands underneath the chrome and appears not to have moved — the practical form of2.4.11 Focus Not Obscured. - Label the region
<nav aria-label="On this page">so the rotor distinguishes it from the site navigation.
/* Keep fragment targets clear of the sticky header (2.4.11 Focus Not Obscured). */
.content-prose h2,
.content-prose h3 {
scroll-margin-top: 98px;
}
Because the same list is often generated from the document outline, a broken heading hierarchy shows up here first: a skipped level produces a table of contents with a gap in it, which is a useful early warning that the outline itself needs fixing.
Responsive Navigation Without Losing State
Below the breakpoint, the same links usually collapse behind a toggle. That toggle is a disclosure like any other, but two details are specific to responsive navigation: the button only exists at small widths, and the panel it controls is the same element that renders inline on desktop.
Implementation Guidelines:
- Render one
<nav>and change its presentation with CSS. Rendering two navigations and hiding one duplicates every link in the accessibility tree unless the hidden copy is trulydisplay: none. - Keep
aria-expandedon the toggle accurate at every width. If CSS shows the panel unconditionally on desktop, the attribute is meaningless there — hide the toggle itself withdisplay: noneso no one can reach a control whose state is a lie. - Use
aria-controlspointing at the panel'sid. Support is uneven, but it costs nothing and helps the screen readers that do expose it. - Close the panel on route change. A menu left open across a client-side navigation traps sighted keyboard users behind a panel that no longer matches the page.
'use client';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
export function ResponsiveNav({ links }: { links: Link[] }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// A11y rationale: 2.4.3 Focus Order — a panel left open across a route change
// strands keyboard users in stale UI, so collapse it when the path changes.
useEffect(() => setOpen(false), [pathname]);
return (
<>
<button
type="button"
className="menu-toggle"
aria-expanded={open}
aria-controls="primary-nav"
onClick={() => setOpen((value) => !value)}
>
Menu
</button>
<nav id="primary-nav" aria-label="Primary" data-open={open ? 'true' : 'false'}>
<PrimaryLinks links={links} />
</nav>
</>
);
}
Note what this component does not do: it does not trap focus, and it does not render the panel into a portal. A navigation panel is not a dialog. Trapping focus in it would strand the user in a region they can leave simply by tabbing, and portalling it would break the DOM order that makes the tab sequence sensible in the first place.
How to Verify
Structure is easy to assert and easy to regress, so verify it with a mix of automation and a two-minute manual pass.
- Automated scan. Run axe on the built page and watch for
landmark-one-main,landmark-unique,region(content outside landmarks),page-has-heading-oneandheading-order. Those five rules cover most structural regressions. Wiring the scan into a test run is covered in Automated Accessibility Testing with axe-core. - Landmark rotor pass. Open the page with VoiceOver (
VO+U, then Landmarks) or NVDA (Dto cycle regions). Every region should be named and the names should be distinct. Two entries reading "navigation" is the bug this whole section exists to prevent. - Heading rotor pass. In the same rotor, read the heading list top to bottom. It should sound like a table of contents, with no jumps in level and no headings used for styling.
- Keyboard walk. From a fresh page load, press
Tabonce. The skip link should appear. Activate it and pressTabagain — focus must land on the first control inside<main>, not back in the header. - Current-page check. Navigate client-side to another route and confirm exactly one link reports
aria-current. This is the assertion most likely to break silently after a router refactor, and it is cheap to cover in an end-to-end test.
Key Takeaways
- Landmark regions come from native elements:
<header>,<nav>,<main>,<aside>,<footer>. A<div>-only shell has no landmark list at all. - Ship one
<main>per page, and put every piece of content inside some region. - Name every
<nav>when a page has more than one, and leave the word "navigation" out of the label. - A skip link needs a target that can hold focus —
tabindex="-1"on<main>is the whole fix. - Mark the current page with
aria-current, driven by the router rather than by CSS classes. - Use a disclosure button for dropdown navigation; save
role="menu"for genuine application menus you are prepared to implement fully. - Keep the heading outline sequential — it is the second navigation layer, and it is generated entirely from source order.
Frequently Asked Questions
How many <nav> elements is too many?
There is no hard limit, but every navigation region shows up in the landmark list, so five unnamed navs are worse than useless. A practical rule: if a group of links is worth jumping to as a unit, make it a labelled <nav>; if it is a handful of inline links inside prose, leave it as prose.
Does <section> create a landmark?
Only when it has an accessible name. A bare <section> maps to a generic region that most screen readers ignore. Add aria-labelledby pointing at the section's heading and it becomes a navigable region; without a name, use a <div> and save the reader a meaningless entry.
Is a skip link still needed if the site has landmarks?
Yes. Landmarks help screen reader users, but sighted keyboard users — people using a switch device, or anyone without a mouse — have no rotor. The skip link is the only bypass mechanism they get, and 2.4.1 Bypass Blocks expects one.
Should the skip link be visible all the time?
It does not have to be, but it must become visible on focus and remain in the tab order at all times. Hiding it with display: none or visibility: hidden removes it from the tab order, which defeats the purpose. Some teams show it permanently in the header; that is also conformant and easier to test.
How do I mark the current page in a nested navigation?
Use aria-current="page" on the exact match and aria-current="true" on ancestor entries that merely contain the current URL. Avoid marking several links as page — it tells the user they are in two places at once.
Do landmarks help SEO or just accessibility? They are primarily an accessibility feature, but they encode the same structure that content extraction relies on, so a clean region skeleton usually improves both. Treat that as a side effect rather than the reason: the measurable win is a page a screen reader user can move around in three keystrokes.
Related guides
- Core Accessibility Principles for Modern Frameworks — the parent guide this navigation work sits under.
- Semantic HTML vs ARIA in Component Trees — why native elements beat role attributes.
- Focus Management Strategies for SPAs — where focus goes when the view changes.
- Building an Accessible Mega Menu in React — the disclosure pattern at full scale.
- Accessible Breadcrumbs & In-Page Anchors — location cues and fragment-link focus.
- Roving Tabindex for Navigation Menus — the single-tab-stop keyboard model.
- Implementing Skip Links in the Next.js App Router — making bypass links survive routing.