core accessibility principles for modern frameworks

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 Relationships
  • 2.4.1 Bypass Blocks
  • 2.4.3 Focus Order
  • 2.4.8 Location
  • 4.1.2 Name, Role, Value
The landmark skeleton of a typical framework app shell A page outline divided into named regions. A banner region across the top contains the site logo and a navigation labelled Primary. Below it, a complementary region on the left holds a second navigation labelled Guides, and a wide main region holds the article, which itself contains a navigation labelled Breadcrumb. A contentinfo region runs across the bottom. A caption notes that each region maps to one native element, and that two navigations on one page must carry distinct names. <header> — banner logo, theme control, and the site-wide navigation <nav aria-label="Primary"> <aside> complementary <nav aria-label="Guides"> A second navigation needs its own name, or the rotor lists two identical entries. <main id="main-content"> — the single main region <nav aria-label="Breadcrumb"> <article> — one h1, then the heading outline Headings are the second navigation layer: a rotor lists them in source order, so the outline must match the visual reading order of the page. <footer> — contentinfo

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 a 1.3.1 violation 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" or role="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> an aria-label when 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-labelledby pointing 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.

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.

Why a skip link needs a focusable target Two rows compare the same skip link. In the broken row, activating the link scrolls the page to main but focus stays in the header, so the next Tab press returns to the first navigation link. In the fixed row, the main element carries tabindex minus one, so activating the link moves focus into main and the next Tab press continues with the first control inside the content. Without a focusable target Skip link activated Page scrolls focus never moves Next Tab returns to the nav the block was never bypassed With tabindex="-1" on the target Skip link activated Focus enters main region is announced Next Tab continues inside the block really is bypassed A fragment link only moves focus when its target can hold focus — headings, sections and main elements need tabindex="-1" to qualify.

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 and aria-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.

Decision tree: links, disclosure, or menu button A decision tree. First question: does the control navigate to another page or view? If yes, use a plain list of links inside a labelled nav, where every link is its own tab stop. If no, ask whether it opens a panel of related controls: if yes, use a disclosure button with aria-expanded, which is the right pattern for most dropdown navigation; if no, and it is a list of actions in an application, use a menu button with role menu, arrow-key navigation and a single tab stop. Does activating it navigate to another page or view? Yes No List of links inside a labelled nav every link is a tab stop. No ARIA needed. Does it reveal a panel of related controls? Yes No Disclosure button aria-expanded toggles a plain list of links Menu button role menu, arrow keys one tab stop, app actions

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 h1 per page, matching the page's subject and — ideally — its <title>.
  • Never skip levels going down. Going back up (from h4 to h2) 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 level prop keeps both honest.
  • Check the outline after composition: a <Card> that hard-codes <h3> produces a broken outline the moment someone drops it under an h4.
// 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-top to the height of any sticky header. Without it the target lands underneath the chrome and appears not to have moved — the practical form of 2.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 truly display: none.
  • Keep aria-expanded on the toggle accurate at every width. If CSS shows the panel unconditionally on desktop, the attribute is meaningless there — hide the toggle itself with display: none so no one can reach a control whose state is a lie.
  • Use aria-controls pointing at the panel's id. 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-one and heading-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 (D to 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 Tab once. The skip link should appear. Activate it and press Tab again — 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.