How to check heading structure of a page
Read the headings Chrome computed, not the h1 to h6 elements. Call Accessibility.getFullAXTree over the DevTools protocol and print every node whose role is heading, with its level. On the page below the tag list and the computed outline each hold four entries, and two of those four entries differ.
Why check this
Run this on any page with more than one section, before staging sign-off, and again after a CMS upgrade changes how editors mark up subheadings. The heading list is how a screen reader user moves through a long page, so the list is the page as far as that user is concerned.
The failure it catches is a section with a heading on screen and none in the outline. An editor styles a paragraph to look like a subheading, or a component ships a div with a large bold font. Sighted readers see six sections, the heading list offers four, and the two missing ones can be reached only by reading forward.
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. See the puppeteer API. - One helper, saved as
ax.mjsnext to the scripts in the steps. Every script below imports it.
// ax.mjs - one Chrome, one page, and the tree it computed. Imported by the scripts below.
import { launch } from 'puppeteer-core';
export async function open(url) {
const browser = await launch({ channel: 'chrome', headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
const cdp = await page.createCDPSession();
const goto = (u) => page.goto(u, { waitUntil: 'networkidle2', timeout: 30000 });
if (url) await goto(url);
return {
browser, page, cdp, goto,
async ax() {
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
return nodes
.filter((n) => !n.ignored && n.role?.value !== 'none')
.map((n) => ({
role: n.role?.value ?? '',
name: n.name?.value ?? '',
props: Object.fromEntries((n.properties ?? []).map((p) => [p.name, p.value?.value])),
}));
},
close: () => browser.close(),
};
}
- A local page with known defects. This one has four: a styled
divthat looks like a heading, aspancarryingrole="heading", a level skipped from 2 to 4, and a heading markedaria-hidden. Save it asheading-demo.mjs. Pick a port nothing else is using, and stop the server afterwards by its PID, never by image name.
// heading-demo.mjs - a docs page with four planted heading defects. node heading-demo.mjs
import { createServer } from 'node:http';
const page = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Heading demo</title><style>
body{font:16px system-ui;margin:2rem;max-width:40rem}
.looks-like-h2{font-size:1.5rem;font-weight:700;margin:1.2rem 0 .4rem}
h3{font-size:1.15rem}
</style></head><body>
<h1>Billing</h1>
<p>How invoices are issued.</p>
<div class="looks-like-h2">Payment methods</div>
<p>Cards and bank transfer.</p>
<span role="heading" aria-level="2">Refunds</span>
<p>Fourteen days.</p>
<h4>Partial refunds</h4>
<p>Pro rata.</p>
<h2 aria-hidden="true">Legacy plans</h2>
<p>Closed to new customers.</p>
<h1>Contact</h1>
</body></html>`;
createServer((_, res) => res.end(page)).listen(9151, () => console.log('http://localhost:9151/'));
- 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 a visual-only heading fails.
Steps
- Step 1.
Start the demo server and leave it running in its own terminal.
node heading-demo.mjshttp://localhost:9151/ - Step 2.
Print the tag list and the computed outline side by side. The walk descends open shadow roots, for the same reason the tab-stop script on How to test keyboard navigation on a website does: a component library keeps its section headings inside a shadow root, and a plain document query never reaches them.
// headings.mjs - node headings.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { console.log('-- tag outline (querySelectorAll) --'); console.log(await s.page.evaluate(() => { const walk = (root, out) => { for (const e of root.querySelectorAll('h1,h2,h3,h4,h5,h6')) out.push(` ${e.tagName} "${e.textContent.trim().slice(0, 40)}"`); for (const e of root.querySelectorAll('*')) if (e.shadowRoot) walk(e.shadowRoot, out); return out; }; return walk(document, []).join('\n'); })); console.log('-- computed tree (Accessibility.getFullAXTree) --'); for (const n of await s.ax()) if (n.role === 'heading') console.log(` level ${n.props.level} "${n.name}"`); } finally { await s.close(); }node headings.mjs http://localhost:9151/-- tag outline (querySelectorAll) -- H1 "Billing" H4 "Partial refunds" H2 "Legacy plans" H1 "Contact" -- computed tree (Accessibility.getFullAXTree) -- level 1 "Billing" level 2 "Refunds" level 4 "Partial refunds" level 1 "Contact"Four entries each, and not the same four. The tag list carries
Legacy plans, dropped by the tree foraria-hidden. The tree carriesRefunds, missed by the selector because the element is aspanwithrole="heading". - Step 3.
Find the text that looks like a heading and is in neither list. The scan keeps every leaf element whose font is at least 1.2 times the body size and at least semibold.
// fake-headings.mjs - node fake-headings.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { console.log(await s.page.evaluate(() => { const base = parseFloat(getComputedStyle(document.body).fontSize); const out = []; for (const e of document.querySelectorAll('body *')) { if (/^(H1|H2|H3|H4|H5|H6)$/.test(e.tagName)) continue; if (e.getAttribute('role') === 'heading') continue; if (!e.textContent.trim() || e.children.length) continue; const c = getComputedStyle(e); if (parseFloat(c.fontSize) >= base * 1.2 && Number(c.fontWeight) >= 600) { out.push(` ${e.tagName.toLowerCase()}.${e.className} "${e.textContent.trim().slice(0, 30)}" ${c.fontSize}/${c.fontWeight} role=${e.getAttribute('role') ?? '(none)'}`); } } return out.join('\n') || ' (none)'; })); } finally { await s.close(); }node fake-headings.mjs http://localhost:9151/div.looks-like-h2 "Payment methods" 24px/700 role=(none)One candidate, 24 px against a 16 px body. Read it and decide: a heading, or emphasised text.
- Step 4.
Print the computed outline with the level rules applied, so the result is a verdict and not a list.
// outline.mjs - node outline.mjs <url> import { open } from './ax.mjs'; const s = await open(process.argv[2]); try { const headings = (await s.ax()).filter((n) => n.role === 'heading'); let prev = 0, h1 = 0; for (const h of headings) { const level = Number(h.props.level); const flags = []; if (level === 1 && ++h1 > 1) flags.push('SECOND H1'); if (prev && level > prev + 1) flags.push(`SKIPPED ${prev + 1}..${level - 1}`); if (!prev && level !== 1) flags.push('DOES NOT START AT 1'); console.log(`${' '.repeat(level - 1)}${level} ${h.name || '(no name)'}${flags.length ? ' <-- ' + flags.join(', ') : ''}`); prev = level; } console.log(`${headings.length} headings, ${h1} at level 1`); } finally { await s.close(); }node outline.mjs http://localhost:9151/1 Billing 2 Refunds 4 Partial refunds <-- SKIPPED 3..3 1 Contact <-- SECOND H1 4 headings, 2 at level 1The indent is the level, so a broken hierarchy shows before you read a flag.
- Step 5.
Run the same script against a page that passes, to see the shape you are aiming at.
node outline.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP1 HTTP: Hypertext Transfer Protocol 2 Guides 3 Security and privacy 3 Related resources 2 Tools & resources 2 Reference 2 Help improve MDN 2 In this article 8 headings, 1 at level 1One level 1, no skips, and every level 3 sits under a level 2. Captured on 2026-09-11.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| The tag list and the tree hold different entries | aria-hidden, role="heading" or a shadow root is in play | Trust the tree. It is what assistive technology receives |
| A candidate in step 3 | Text is styled as a heading and marked as body text | Replace the div with the heading element for its level |
| SKIPPED 3..3 | A level was jumped, so the outline has a hole | Renumber the section, or add the missing level |
| SECOND H1 | The document has two roots | Demote one, unless each is a separate top-level article |
| (no name) at any level | The heading element is empty or holds only an image | Give it text, or an image alternative |
Common mistakes
What to check next
- How to check accessibility tree: the same
getFullAXTreecall, read node by node rather than filtered to headings. - How to check landmark regions of a page: the other structural list a screen reader user navigates by.
- How to check aria labels: why a heading node can carry a name that differs from its text.
- How to check link text for accessibility: the same computed-name reading, applied to every link on the page.
FAQ
How do I check heading structure without a browser?
You cannot get the computed outline without one. A parser reads h1 to h6 from the markup, which misses role="heading", keeps aria-hidden headings and never resolves a shadow root. Use it as a first filter and confirm in the tree.
How do I test heading order?
Step 4 does it. Read the computed level of each heading in order, and flag any step of more than one level, any document that does not open at level 1, and any second level 1. The indent makes a hole in the hierarchy visible without reading the flags.
Is a skipped heading level a WCAG failure?
WCAG 2.2 does not name a skipped level as a failure of 1.3.1. Audit tools report it as a warning, because the heading list is the navigation and a hole in it sends the reader to the wrong section. Treat it as a defect with a low severity.
Should a page have exactly one h1?
One is the safe answer and the one the demo flags against. HTML allows more, and a page of several independent articles has a case for it. The test is whether a reader jumping between level 1 headings lands on peers.
Why does the tree show a heading with no name?
The element is empty, holds only an image with no alternative, or its text sits inside an aria-hidden child. Chrome computes the name from contents, so nothing readable in means nothing out. The entry stays in the list with no destination.
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
intermediate9 minpublished updated Maks Verny