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
- Node 22 and Chrome on the same machine.
npm i puppeteer-coreinstalls the driver only: it ships no browser and drives the Chrome already installed. - The
ax.mjshelper from How to check heading structure of a page, saved next to the scripts below. Both scripts import it. - A local page served twice: once as shipped, once with the navigation landmarks named. Save it as
landmark-demo.mjs, on a port nothing else is using, and stop it afterwards by its PID.
// 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')
);
- The figures below come from one capture, on one machine, with Chrome 152.0.7977.76.
- WCAG 1.3.1 Info and Relationships is the Level A criterion behind the structure, and ARIA11 is the technique that names landmarks as a way to meet it.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node landmark-demo.mjshttp://localhost:9152/ and http://localhost:9152/named - 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
headerinside thearticleproduced no banner, and thesectionaround Filters produced no region, because it carries no accessible name. Thesectionaround Results carriesaria-labelledbyand did become a region. - 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/namedbanner "" navigation "Breadcrumb" main "" complementary "" contentinfo "" navigation "Main" region "Results" form "" navigation "" 9 landmarks, 0 indistinguishable pair(s) …Two
aria-labelattributes, the same nine landmarks, and the group that could not be told apart is gone. The third navigation, the pager built from adiv, is still unnamed and still needs a name. - 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/namedindex 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.
- 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/HTTPbanner "" 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
What to check next
- How to check accessibility tree: the full tree this check filters down to nine rows.
- How to check heading structure of a page: the other list a screen reader user navigates by, read the same way.
- How to check if a form is accessible: what the unnamed
formlandmark in step 2 contains, control by control. - How to check aria labels: where the names added in step 3 come from, and the ways they fail to apply.
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.
Related on this site
intermediate10 minpublished updated Maks Verny