Screen reader users skim a page by its headings, the way sighted readers scan the bold lines.A heading's level says where its section sits in the outline, not how big the text is: CSS decides the size.
Use it with a mouse, a keyboard or a screen reader.The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Headings
0found
H1This page's own name, above the demo
Looks like a heading, but is not
Every level follows the one before it.
A weekend in Udaipur
By Meera Iyer · 6 min read
Lakes, palaces and rooftop dinners: two days are enough for the old city, if you plan the mornings.
The airport is about 22 km from the old city, a 40-minute taxi ride.
Where to stay
Haveli guesthouses near Gangaur Ghat are a short walk from Lake Pichola.
What to eat
Dal baati churma at a family thali place, and kachori near Jagdish Temple for breakfast.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
Key
What it does
Tab
Moves to Show structure, then through the headings list.
Enter or Space
On an entry in the headings list, moves focus to that heading in the article; the next Tab carries on from there.
Space
On Show structure, labels every heading in the article with its level, and marks bold text that only looks like one.
H or 1 or 2 or 3
Screen reader keys, not the page's: H moves to the next heading and a number to the next heading at that level, in NVDA and JAWS. The markup is all they need.
Screen readers
What it announces
Written from the roles, names and states in the markup.
When
Expected announcement
Enter on H3 Getting there in the list
Getting there, heading, level 3
A screen reader lists the headings
A weekend in Udaipur, level 2; Getting there, level 3; By train, level 4; By air, level 4; Where to stay, level 3; What to eat, level 3
Broken version: the list reaches Getting there
H4 Getting there, Level jumps from 2 to 4, button
Broken version: a screen reader reads By train
By train (plain text: H skips it, and it is missing from the headings list)
Show structure is switched on
Show structure, switch, on (checked)
These are expected announcements, not recordings.Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on.The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
The markup
<div class="ap-hd" data-ap-headings>
<div class="ap-hd__rotor">
<button type="button" class="ap-hd__switch" role="switch" aria-checked="false" data-hd-structure>
<span class="ap-hd__track" aria-hidden="true"><span class="ap-hd__knob"></span></span>
Show structure
</button>
<div class="ap-hd__rotor-top">
<p class="ap-hd__rotor-name" id="hd-rotor-name">Headings</p>
<p class="ap-hd__count"><span data-hd-count>0</span> <span>found</span></p>
</div>
<p class="ap-hd__context"><span class="ap-hd__lvl" translate="no">H1</span> <span>This page's own name, above the demo</span></p>
<ol class="ap-hd__list" role="list" aria-labelledby="hd-rotor-name" data-hd-list></ol>
<div class="ap-hd__fakes" data-hd-fakes hidden>
<p class="ap-hd__fakes-name" id="hd-fakes-name">Looks like a heading, but is not</p>
<ul class="ap-hd__fake-list" role="list" aria-labelledby="hd-fakes-name" data-hd-fake-list></ul>
</div>
<p class="ap-hd__verdict" data-hd-verdict>
<svg class="ap-hd__verdict-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path class="ap-hd__ok" d="m5 12 5 5L20 7"/><path class="ap-hd__bad" d="M12 8v5m0 3.5v.5"/></svg>
<span data-hd-verdict-text>Every level follows the one before it.</span>
<span data-hd-problems hidden></span>
</p>
</div>
<article class="ap-hd__article" data-hd-good>
<h2 class="ap-hd__h2">A weekend in Udaipur</h2>
<p class="ap-hd__byline">By Meera Iyer · 6 min read</p>
<p>Lakes, palaces and rooftop dinners: two days are enough for the old city, if you plan the mornings.</p>
<h3 class="ap-hd__h3">Getting there</h3>
<h4 class="ap-hd__h4">By train</h4>
<p>Overnight trains from Delhi reach <a href="#hd-station">Udaipur City station</a> early in the morning.</p>
<h4 class="ap-hd__h4">By air</h4>
<p>The airport is about 22 km from the old city, a 40-minute taxi ride.</p>
<h3 class="ap-hd__h3">Where to stay</h3>
<p><a href="#hd-havelis">Haveli guesthouses</a> near Gangaur Ghat are a short walk from Lake Pichola.</p>
<h3 class="ap-hd__h3">What to eat</h3>
<p>Dal baati churma at a family thali place, and kachori near Jagdish Temple for breakfast.</p>
</article>
<!-- The broken version, for comparison only: the same classes, so it
looks the same, but the tags are wrong. Do not copy this one. -->
<article class="ap-hd__article" data-hd-broken hidden>
<h2 class="ap-hd__h2">A weekend in Udaipur</h2>
<h5 class="ap-hd__byline">By Meera Iyer · 6 min read</h5>
<p>Lakes, palaces and rooftop dinners: two days are enough for the old city, if you plan the mornings.</p>
<h4 class="ap-hd__h3">Getting there</h4>
<p class="ap-hd__h4"><b>By train</b></p>
<p>Overnight trains from Delhi reach <a href="#hd-station">Udaipur City station</a> early in the morning.</p>
<p class="ap-hd__h4"><b>By air</b></p>
<p>The airport is about 22 km from the old city, a 40-minute taxi ride.</p>
<h3 class="ap-hd__h3">Where to stay</h3>
<p><a href="#hd-havelis">Haveli guesthouses</a> near Gangaur Ghat are a short walk from Lake Pichola.</p>
<h3 class="ap-hd__h3">What to eat</h3>
<p>Dal baati churma at a family thali place, and kachori near Jagdish Temple for breakfast.</p>
</article>
</div>
/**
* Heading structure: an article whose heading levels follow its outline,
* a headings list built from the markup (like a screen reader's) that moves
* focus to each heading and flags what is wrong, and a switch that labels
* every heading with its level on screen.
*
* Markup: [data-ap-headings] holding article[data-hd-good] (and, for the
* demo's comparison, article[data-hd-broken]), a list ol[data-hd-list], a
* count [data-hd-count], a group [data-hd-fakes] with ul[data-hd-fake-list],
* a verdict [data-hd-verdict] and a button[role=switch][data-hd-structure].
* Add data-broken to the root to show the broken article instead.
*/
const HEADINGS = "h1, h2, h3, h4, h5, h6, [role=heading]";
const PAGE_LEVEL = 1; // the page's own h1 sits above the demo
const levelOf = (el) => Number(el.getAttribute("aria-level")) || Number(el.localName.slice(1)) || 2;
/** Text as a screen reader gets it: parts marked aria-hidden left out. */
function textOf(node) {
let out = "";
for (const child of node.childNodes) {
if (child.nodeType === 3) out += child.data;
else if (child.nodeType === 1 && child.getAttribute("aria-hidden") !== "true") out += textOf(child);
}
return out.replace(/\s+/g, " ").trim();
}
function span(className, text, raw = false) {
const s = document.createElement("span");
s.className = className;
s.textContent = text;
if (raw) s.setAttribute("translate", "no");
return s;
}
export function init(root) {
const list = root.querySelector("[data-hd-list]");
const count = root.querySelector("[data-hd-count]");
const fakes = root.querySelector("[data-hd-fakes]");
const fakeList = root.querySelector("[data-hd-fake-list]");
const verdict = root.querySelector("[data-hd-verdict]");
const verdictText = root.querySelector("[data-hd-verdict-text]");
const problems = root.querySelector("[data-hd-problems]");
const toggle = root.querySelector("[data-hd-structure]");
const good = root.querySelector("[data-hd-good]");
const broken = root.querySelector("[data-hd-broken]");
const tabbed = new Set();
let found = [];
let lookalikes = [];
// The option: show the broken article in place of the good one.
const showBroken = root.hasAttribute("data-broken") && broken;
good.hidden = !!showBroken;
if (broken) broken.hidden = !showBroken;
const article = showBroken ? broken : good;
/* What a reviewer checks, read from the live page. */
function scan() {
const bodySize = parseFloat(getComputedStyle(article).fontSize);
found = [];
for (const el of article.querySelectorAll(HEADINGS)) {
const level = levelOf(el);
const issues = [];
// A level may go one deeper than the nearest heading above it, no more.
const parent = [...found].reverse().find((h) => h.level < level);
const from = parent ? parent.level : PAGE_LEVEL;
if (level > from + 1) issues.push(`Level jumps from ${from} to ${level}`);
if (!textOf(el)) issues.push("Empty heading");
if (parseFloat(getComputedStyle(el).fontSize) < bodySize) issues.push("Smaller than the body text: is it here for its size?");
found.push({ el, level, name: textOf(el), issues });
}
// Bold lines that look like headings but are paragraphs.
lookalikes = [...article.querySelectorAll("p")].filter((p) => {
const only = p.children.length === 1 ? p.firstElementChild : null;
return only && /^(b|strong)$/i.test(only.localName) && textOf(only) && textOf(only) === textOf(p);
});
}
function render() {
list.replaceChildren();
found.forEach((h, i) => {
const li = document.createElement("li");
li.className = "ap-hd__entry";
li.dataset.level = String(h.level);
const button = document.createElement("button");
button.type = "button";
button.className = "ap-hd__jump";
button.dataset.hdIndex = String(i);
const lvl = span(`ap-hd__lvl${h.issues.length ? " ap-hd__lvl--bad" : ""}`, `H${h.level}`, true);
button.append(lvl, span("ap-hd__entry-name", h.name));
for (const issue of h.issues) button.append(span("ap-hd__issue", issue));
li.append(button);
list.append(li);
});
count.textContent = String(found.length);
fakeList.replaceChildren();
for (const p of lookalikes) {
const li = document.createElement("li");
li.className = "ap-hd__fake";
li.append(span("ap-hd__lvl ap-hd__lvl--bad", "p", true), span("", textOf(p)));
fakeList.append(li);
}
fakes.hidden = lookalikes.length === 0;
const total = found.reduce((n, h) => n + h.issues.length, 0) + lookalikes.length;
verdict.dataset.state = total ? "bad" : "ok";
verdictText.textContent = total ? "Problems found:" : "Every level follows the one before it.";
problems.hidden = !total;
problems.textContent = total ? String(total) : "";
}
/* Show structure: a level badge inside each heading, hidden from assistive technology. */
function paintStructure(on) {
for (const tag of article.querySelectorAll(".ap-hd__tag")) tag.remove();
if (!on) return;
for (const h of found) {
const tag = span(`ap-hd__lvl ap-hd__tag${h.issues.length ? " ap-hd__lvl--bad" : ""}`, `H${h.level}`, true);
tag.setAttribute("aria-hidden", "true");
h.el.prepend(tag);
}
for (const p of lookalikes) {
const tag = span("ap-hd__lvl ap-hd__lvl--bad ap-hd__tag ap-hd__tag--fake", "Not a heading");
tag.setAttribute("aria-hidden", "true");
p.prepend(tag);
}
}
function jump(el) {
if (!el.hasAttribute("tabindex")) {
el.setAttribute("tabindex", "-1");
tabbed.add(el);
}
el.focus();
el.classList.remove("is-landed");
void el.offsetWidth; // restart the highlight
el.classList.add("is-landed");
}
function onClick(event) {
const entry = event.target.closest(".ap-hd__jump");
if (entry && list.contains(entry)) {
const h = found[Number(entry.dataset.hdIndex)];
if (h) jump(h.el);
return;
}
if (event.target.closest("[data-hd-structure]") === toggle) {
const on = toggle.getAttribute("aria-checked") !== "true";
toggle.setAttribute("aria-checked", String(on));
paintStructure(on);
}
}
// tabindex="-1" only for the jump: a later click should not focus the heading.
function onFocusOut(event) {
if (tabbed.has(event.target)) {
event.target.removeAttribute("tabindex");
tabbed.delete(event.target);
}
}
function onAnimationEnd(event) {
event.target.classList.remove("is-landed");
}
scan();
render();
paintStructure(toggle.getAttribute("aria-checked") === "true");
root.addEventListener("click", onClick);
article.addEventListener("focusout", onFocusOut);
article.addEventListener("animationend", onAnimationEnd);
return () => {
root.removeEventListener("click", onClick);
article.removeEventListener("focusout", onFocusOut);
article.removeEventListener("animationend", onAnimationEnd);
paintStructure(false);
for (const el of tabbed) el.removeAttribute("tabindex");
list.replaceChildren();
fakeList.replaceChildren();
};
}
for (const root of document.querySelectorAll("[data-ap-headings]")) init(root);
Show structure is a button with role=switch and aria-checked, so its name, role and on or off state are exposed.
Usage
When to use it
Use it
Any page with more than one section: articles, settings pages, product pages, help pages.
Components that start a section, like a card group or a dialog: give them a heading at the level where they are placed.
Use something else
Text that does not start a section, like a byline, a price or a tagline, however big or small it is drawn.
Headings for every card in a long list, when the list itself is the only thing people jump to.
Common failures
How it usually goes wrong
Skipping levels for the look
Going from h2 to h4 because h4 is the size you wanted breaks the outline. Pick the level by position and set the size with a class.
Bold text as a heading
A bold paragraph looks like a heading but is read as plain text and never appears in the headings list, so its whole section is hidden from people moving by H.
A heading chosen for its size
An h5 byline or an h2 price puts noise in the headings list. If it does not start a section, it is a paragraph with a class.
A fixed level inside a component
A card that always renders h3 lands under an h4 somewhere else. Let whoever places the component choose its level.
Empty or icon-only headings
A heading with no text, or only an icon, is announced as heading level 3 and nothing else. Every heading needs words.
No h1, or several
The page's name belongs in one h1. Components dropped into a page should not bring their own.
Notes
Building it
The article starts at h2 because this page's own name is its h1. On your page, start where the content sits: one h1 for the page's name, h2 for its main sections, and so on down.
Style by class, not by tag: the broken version uses the same classes, so both look alike and only the tags differ, which is exactly why the problems go unnoticed.
The headings list is built from the markup the way a screen reader builds its own, and checks what a reviewer would: level jumps against the nearest higher heading, headings set smaller than the body text, and bold lines that look like headings.
aria-level on a heading changes only what is announced. Change the element instead, so the page, the outline and the styles agree.
A heading reached from the list gets tabindex="-1" for the jump only, so a later click on it does not draw a focus ring.
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard.Not yet tested with every screen reader and browser pair.Report a correction