# Landmark Regions

**MUST** · **ID:** `layout-aria-landmarks` · **Category:** layout
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/layout-aria-landmarks

> MUST: Put all perceivable content inside a landmark, built from the native elements (`<header>` banner, `<nav>`, `<main>`, `<aside>`, `<footer>` contentinfo) rather than roles on divs — a div soup gives the screen-reader rotor nothing to jump to. Give each DUPLICATE landmark a unique `aria-label` (or `aria-labelledby` on its heading), and never put the role name in the label: "Site Navigation" announces as "Site Navigation navigation" — label it "Primary" and "Breadcrumb".

Put all perceivable content inside a landmark, use the native sectioning elements, and label duplicates

Landmarks are the structural layer of a page, and nothing else in this corpus covers them. layout-interface-screen-grounding is about visible wayfinding chrome — a sidebar, breadcrumbs, user context — which is a different substance: you can build all of it out of divs and still ship a page with zero landmarks. Screen readers expose a landmark rotor as the primary way to jump around a page; a div soup produces an empty rotor, so the only way to reach the main content is to walk every node from the top. Four rules cover it. First, every perceivable region belongs to a landmark. Second, use the native elements — header (banner at body scope), nav (navigation), main (main), aside (complementary), footer (contentinfo at body scope) — rather than role attributes on divs. Third, when a landmark type appears more than once, give each instance a unique label with aria-label, or aria-labelledby pointing at its heading; two unlabelled navs are indistinguishable in the rotor. Fourth, keep the role name out of the label: the role is announced already, so "Site Navigation" becomes "Site Navigation navigation". Label it "Primary" and "Breadcrumb", not "Primary Navigation".

## Rule snippet

```tsx
<nav aria-label="Primary">…</nav>
<nav aria-label="Breadcrumb">…</nav>
```

## Bad — do not do this

`layout-aria-landmarks-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

/** HTML sectioning elements and the landmark roles they map to at page scope (APG). */
const IMPLIED_ROLE: Record<string, string> = {
  HEADER: 'banner',
  NAV: 'navigation',
  MAIN: 'main',
  ASIDE: 'complementary',
  FOOTER: 'contentinfo',
};

const LANDMARK_ROLES = new Set([
  'banner',
  'navigation',
  'main',
  'complementary',
  'contentinfo',
  'region',
  'search',
  'form',
]);

interface Landmark {
  role: string;
  name: string;
}

function accessibleName(el: Element): string {
  const labelledBy = el.getAttribute('aria-labelledby');
  if (labelledBy) {
    const target = document.getElementById(labelledBy);
    if (target?.textContent) return target.textContent.trim();
  }
  return el.getAttribute('aria-label')?.trim() ?? '';
}

/** The list a screen-reader user gets from the landmark rotor, built from the real DOM of the pane. */
function collectLandmarks(root: HTMLElement): Landmark[] {
  const nodes = root.querySelectorAll<HTMLElement>('header, nav, main, aside, footer, [role]');
  const found: Landmark[] = [];
  nodes.forEach((el) => {
    const explicit = el.getAttribute('role');
    const role = explicit && LANDMARK_ROLES.has(explicit) ? explicit : IMPLIED_ROLE[el.tagName];
    if (!role) return;
    found.push({ role, name: accessibleName(el) });
  });
  return found;
}

export function AriaLandmarksBad() {
  const mockRef = useRef<HTMLDivElement>(null);
  const [landmarks, setLandmarks] = useState<Landmark[] | null>(null);

  useEffect(() => {
    if (mockRef.current) setLandmarks(collectLandmarks(mockRef.current));
  }, []);

  return (
    <div className="w-full max-w-md space-y-4">
      {/* aria-hidden so this mock page does not add duplicate landmarks to the real page around it. */}
      <div
        ref={mockRef}
        aria-hidden="true"
        className="rounded-lg border border-border bg-card p-3 text-xs text-foreground"
      >
        <div className="header flex items-center justify-between rounded bg-muted p-2">
          <span className="font-semibold">Acme</span>
          <span className="text-muted-foreground">Sign out</span>
        </div>
        <div className="nav mt-2 flex gap-3 rounded bg-muted p-2 text-muted-foreground">
          <span>Dashboard</span>
          <span>Invoices</span>
          <span>Settings</span>
        </div>
        <div className="breadcrumb mt-2 rounded bg-muted p-2 text-muted-foreground">Home / Invoices / #1042</div>
        <div className="content mt-2 flex gap-2">
          <div className="main flex-1 rounded bg-muted p-2">
            <p className="font-medium">Invoice #1042</p>
            <p className="text-muted-foreground">Due 1 August</p>
          </div>
          <div className="sidebar w-24 rounded bg-muted p-2 text-muted-foreground">Related</div>
        </div>
        <div className="footer mt-2 rounded bg-muted p-2 text-muted-foreground">© Acme</div>
      </div>

      <div className="rounded-lg border border-border bg-card p-3">
        <p className="text-xs font-semibold text-foreground mb-2">Screen-reader landmark rotor</p>
        {landmarks && landmarks.length === 0 ? (
          <p className="font-mono text-xs text-destructive">No landmarks found.</p>
        ) : (
          <ol className="space-y-1">
            {landmarks?.map((l, i) => (
              <li key={i} className="font-mono text-xs text-foreground">
                {`${l.name} ${l.role}`.trim()}
              </li>
            ))}
          </ol>
        )}
        <p className="mt-2 text-xs text-muted-foreground">
          Every region above is a <code>div</code> with a class name. Class names carry no semantics, so the rotor
          is empty and the page is one undifferentiated block to jump through.
        </p>
      </div>

      <p className="text-xs text-destructive">
        Rotor list computed live from this pane&apos;s DOM. Nothing to skip to, no way to reach the main content
        without walking the whole page.
      </p>
    </div>
  );
}
```

## Good — do this

`layout-aria-landmarks-good`

```tsx
import { useEffect, useRef, useState } from 'react';

/** HTML sectioning elements and the landmark roles they map to at page scope (APG). */
const IMPLIED_ROLE: Record<string, string> = {
  HEADER: 'banner',
  NAV: 'navigation',
  MAIN: 'main',
  ASIDE: 'complementary',
  FOOTER: 'contentinfo',
};

const LANDMARK_ROLES = new Set([
  'banner',
  'navigation',
  'main',
  'complementary',
  'contentinfo',
  'region',
  'search',
  'form',
]);

interface Landmark {
  role: string;
  name: string;
}

function accessibleName(el: Element): string {
  const labelledBy = el.getAttribute('aria-labelledby');
  if (labelledBy) {
    const target = document.getElementById(labelledBy);
    if (target?.textContent) return target.textContent.trim();
  }
  return el.getAttribute('aria-label')?.trim() ?? '';
}

/** The list a screen-reader user gets from the landmark rotor, built from the real DOM of the pane. */
function collectLandmarks(root: HTMLElement): Landmark[] {
  const nodes = root.querySelectorAll<HTMLElement>('header, nav, main, aside, footer, [role]');
  const found: Landmark[] = [];
  nodes.forEach((el) => {
    const explicit = el.getAttribute('role');
    const role = explicit && LANDMARK_ROLES.has(explicit) ? explicit : IMPLIED_ROLE[el.tagName];
    if (!role) return;
    found.push({ role, name: accessibleName(el) });
  });
  return found;
}

export function AriaLandmarksGood() {
  const mockRef = useRef<HTMLDivElement>(null);
  const [landmarks, setLandmarks] = useState<Landmark[]>([]);

  useEffect(() => {
    if (mockRef.current) setLandmarks(collectLandmarks(mockRef.current));
  }, []);

  return (
    <div className="w-full max-w-md space-y-4">
      {/* aria-hidden so this mock page does not add duplicate landmarks to the real page around it. */}
      <div
        ref={mockRef}
        aria-hidden="true"
        className="rounded-lg border border-border bg-card p-3 text-xs text-foreground"
      >
        <header className="flex items-center justify-between rounded bg-muted p-2">
          <span className="font-semibold">Acme</span>
          <span className="text-muted-foreground">Sign out</span>
        </header>
        {/* Two navigation landmarks on one page, so each gets a unique label. */}
        <nav aria-label="Primary" className="mt-2 flex gap-3 rounded bg-muted p-2 text-muted-foreground">
          <span>Dashboard</span>
          <span>Invoices</span>
          <span>Settings</span>
        </nav>
        <nav aria-label="Breadcrumb" className="mt-2 rounded bg-muted p-2 text-muted-foreground">
          Home / Invoices / #1042
        </nav>
        <div className="mt-2 flex gap-2">
          <main className="flex-1 rounded bg-muted p-2">
            <p className="font-medium">Invoice #1042</p>
            <p className="text-muted-foreground">Due 1 August</p>
          </main>
          <aside aria-label="Related invoices" className="w-24 rounded bg-muted p-2 text-muted-foreground">
            Related
          </aside>
        </div>
        <footer className="mt-2 rounded bg-muted p-2 text-muted-foreground">© Acme</footer>
      </div>

      <div className="rounded-lg border border-border bg-card p-3">
        <p className="text-xs font-semibold text-foreground mb-2">Screen-reader landmark rotor</p>
        <ol className="space-y-1">
          {landmarks.map((l, i) => (
            <li key={i} className="font-mono text-xs text-foreground">
              {`${l.name} ${l.role}`.trim()}
            </li>
          ))}
        </ol>
        <p className="mt-2 text-xs text-muted-foreground">
          Roles shown as they map at page scope. The label is &quot;Primary&quot;, never &quot;Primary
          Navigation&quot; — the role is announced already, so that would read as &quot;Primary Navigation
          navigation&quot;.
        </p>
      </div>

      <p className="text-xs text-success">
        Rotor list computed live from this pane&apos;s DOM: six labelled regions, so a keyboard or screen-reader
        user can jump straight to main, and the two navigations are told apart.
      </p>
    </div>
  );
}
```

## References

- [APG: Landmark Regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/)
- [MDN: <nav>](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/nav)
