How to check landmark regions of a page

Read the landmarks Chrome computed, not the header, nav and main elements. Filter Accessibility.getFullAXTree to the landmark roles and print each role with its accessible name. On the page below a selector finds eleven elements, the tree holds nine landmarks, and three of them are navigation regions with no name.

Why check this

Run this on every page template, before staging sign-off, and again when a layout component is replaced. A screen reader user opens the landmark list to jump from the header to the main content or to the site navigation, in the same way a sighted user throws their eyes at the page.

The failure it catches is a list the reader cannot choose from. A template with a site menu, a breadcrumb trail and a pager produces three entries that all read navigation, and picking one is guesswork. The reader lands in the pager, comes back, tries the next one, and gives up on the shortcut.

Prerequisites

// landmark-demo.mjs  -  / has the defects, /named has the same page with the navs named. node landmark-demo.mjs
import { createServer } from 'node:http';
const body = (named) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Landmark demo</title></head><body>
<header><b>Shop</b>
  <nav${named ? ' aria-label="Main"' : ''}><a href="/a">Catalogue</a> <a href="/b">Basket</a></nav>
</header>
<nav${named ? ' aria-label="Breadcrumb"' : ''}><a href="/">Home</a> / <a href="/a">Catalogue</a></nav>
<main>
  <h1>Catalogue</h1>
  <section><h2>Filters</h2><p>Price, size.</p></section>
  <section aria-labelledby="res"><h2 id="res">Results</h2><p>12 items.</p></section>
  <form><label>Search <input name="q"></label></form>
  <article><header><h2>Blue shirt</h2></header><p>Cotton.</p></article>
  <div role="navigation">1 2 3</div>
</main>
<aside><h2>Recently viewed</h2></aside>
<footer><p>Copyright</p></footer>
</body></html>`;
createServer((req, res) => res.end(body(req.url.startsWith('/named')))).listen(9152, () =>
  console.log('http://localhost:9152/  and  http://localhost:9152/named')
);

Steps

  1. Step 1.

    Start the demo server and leave it running in its own terminal.

    node landmark-demo.mjs
    
    http://localhost:9152/  and  http://localhost:9152/named
  2. Step 2.

    List the landmarks the browser computed, then list the elements a selector finds, and compare the two counts.

    // landmarks.mjs  -  node landmarks.mjs <url>
    import { open } from './ax.mjs';
    const LANDMARKS = ['banner', 'navigation', 'main', 'complementary', 'contentinfo', 'region', 'form', 'search'];
    const s = await open(process.argv[2]);
    try {
      const found = (await s.ax()).filter((n) => LANDMARKS.includes(n.role));
      for (const n of found) console.log(`  ${n.role.padEnd(14)} "${n.name}"`);
      const seen = {};
      for (const n of found) seen[`${n.role}|${n.name}`] = (seen[`${n.role}|${n.name}`] ?? 0) + 1;
      const clashes = Object.entries(seen).filter(([, c]) => c > 1);
      console.log(`${found.length} landmarks, ${clashes.length} indistinguishable pair(s)`);
      for (const [key, c] of clashes) console.log(`  ${c} x ${key.split('|')[0]} with name "${key.split('|')[1]}"`);
      console.log('-- elements a selector finds --');
      console.log(await s.page.evaluate(() =>
        [...document.querySelectorAll('header,nav,main,aside,footer,section,form,[role]')]
          .map((e) => `  <${e.tagName.toLowerCase()}${e.getAttribute('role') ? ` role=${e.getAttribute('role')}` : ''}>`)
          .join('\n')
      ));
    } finally {
      await s.close();
    }
    
    node landmarks.mjs http://localhost:9152/
    
      banner         ""
    navigation     ""
    main           ""
    complementary  ""
    contentinfo    ""
    navigation     ""
    region         "Results"
    form           ""
    navigation     ""
    9 landmarks, 1 indistinguishable pair(s)
    3 x navigation with name ""
    -- elements a selector finds --
    <header>
    <nav>
    <nav>
    <main>
    <section>
    <section>
    <form>
    <header>
    <div role=navigation>
    <aside>
    <footer>

    Eleven elements, nine landmarks. The header inside the article produced no banner, and the section around Filters produced no region, because it carries no accessible name. The section around Results carries aria-labelledby and did become a region.

  3. Step 3.

    Run the same script against the version with the two navigation landmarks named, and read the clash line.

    node landmarks.mjs http://localhost:9152/named
    
      banner         ""
    navigation     "Breadcrumb"
    main           ""
    complementary  ""
    contentinfo    ""
    navigation     "Main"
    region         "Results"
    form           ""
    navigation     ""
    9 landmarks, 0 indistinguishable pair(s)
    …

    Two aria-label attributes, the same nine landmarks, and the group that could not be told apart is gone. The third navigation, the pager built from a div, is still unnamed and still needs a name.

  4. Step 4.

    Check the order before you trust it. Print the index each landmark holds in the returned array, with its depth in the tree.

    // landmark-order.mjs  -  node landmark-order.mjs <url>
    import { open } from './ax.mjs';
    const ROLES = ['banner', 'navigation', 'main', 'complementary', 'contentinfo', 'region', 'form'];
    const s = await open(process.argv[2]);
    try {
      const { nodes } = await s.cdp.send('Accessibility.getFullAXTree');
      const by = Object.fromEntries(nodes.map((n) => [n.nodeId, n]));
      const depth = (n) => { let d = 0, c = n; while (c && c.parentId) { c = by[c.parentId]; d++; } return d; };
      for (const n of nodes) {
        if (!ROLES.includes(n.role?.value)) continue;
        console.log(`index ${nodes.indexOf(n)} depth ${depth(n)}  ${n.role.value} "${n.name?.value ?? ''}"`);
      }
    } finally {
      await s.close();
    }
    
    node landmark-order.mjs http://localhost:9152/named
    
    index 3 depth 3  banner ""
    index 4 depth 3  navigation "Breadcrumb"
    index 5 depth 3  main ""
    index 6 depth 3  complementary ""
    index 7 depth 3  contentinfo ""
    index 9 depth 4  navigation "Main"
    index 15 depth 4  region "Results"
    index 16 depth 4  form ""
    index 18 depth 4  navigation ""

    The navigation named Main sits first in the document, inside the header, and arrives ninth. Every depth 3 node comes before every depth 4 node, so the array is in level order, not document order.

  5. Step 5.

    Run it against a real site, where the same problem is larger.

    node landmarks.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
      banner         ""
    main           ""
    complementary  ""
    contentinfo    ""
    navigation     ""
    complementary  ""
    region         "Guides"
    region         "Security and privacy"
    region         "Related resources"
    region         "Tools & resources"
    region         "Reference"
    region         "Help improve MDN"
    navigation     ""
    navigation     ""
    navigation     ""
    15 landmarks, 2 indistinguishable pair(s)
    2 x complementary with name ""
    4 x navigation with name ""
    -- elements a selector finds --
    <header>
    <nav>
    <svg role=img>
    <nav>
    <main>
    …

    Four navigation landmarks with the same empty name, on a documentation site that is maintained carefully. Captured on 2026-09-11.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | N x navigation with name "" | The reader cannot choose between them | Put aria-label on each, naming what it navigates | | More elements than landmarks | Some elements produced no landmark | Expected for a header inside an article and for an unnamed section | | region missing for a section | The section has no accessible name | Add aria-labelledby pointing at its heading, or drop the element | | No main in the list | The page has no main landmark | Add main around the page content, so a skip link has a target | | Two banner or two contentinfo | A header or footer was promoted that should not be | Move it inside article or section, or give it a name |

Common mistakes

Sign: An audit counts landmark elements in the markup and reports a number that is too high.Cause: The selector returned eleven elements and the tree held nine landmarks. A header inside an article is not a banner, and a section with no accessible name is not a region. Counting elements reports landmarks that do not exist, and the reader never gets the entries the report promised.
Sign: Two navigation landmarks look identical in the report and nobody treats it as a defect.Cause: An unnamed nav and a second unnamed nav are one entry to a reader choosing from the landmark list: role navigation, no name. On the demo page three of them collapse into one indistinguishable group, and on the MDN capture four do. Naming each one with aria-label is the whole fix, and it changes nothing on screen.
Sign: A script numbers the landmarks in the order getFullAXTree returned them and the numbers do not match the page.Cause: The array is in level order. Every node at depth 3 arrives before every node at depth 4, so the navigation inside the header, first in the document, came back ninth of nine. Sort by DOM position yourself when the order matters, or walk the parentId chain.

What to check next

FAQ

How do I check ARIA landmarks in the browser?

Open DevTools, Elements panel, the Accessibility pane, and read the role of the selected node. For the whole list at once, use Accessibility.getFullAXTree and keep the landmark roles, as step 2 does. The pane answers one node at a time and cannot show you a clash.

Which elements produce a landmark without any ARIA?

header and footer at the top level of the document, nav, main, aside and form. A section produces a region only when it has an accessible name. A header or footer inside article or section produces nothing, which is what the demo page shows.

How many landmarks should a page have?

There is no number. Every part of the page should sit inside one, and no two of the same role should share a name. The demo page has nine for five visible areas, and two of those nine are worth removing rather than naming.

Does naming a landmark change the page?

No. aria-label sets the accessible name and paints nothing. Step 3 adds two attributes, the rendered page is identical, and the clash in the landmark list disappears.

Should the name repeat the role?

No. aria-label="Main navigation" is announced along with the role, so the reader hears the word navigation twice. Name what it navigates: Main, Breadcrumb, Pagination, Footer.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny