How to check the heading structure of a web page

Headings are the outline of a page: the thing a screen reader navigates by and a search engine reads to work out what the page is about. Checking them takes a minute. Checking them correctly takes knowing the two cases where the obvious method lies to you.

The fastest manual method

Open DevTools with F12, go to the Console tab, and paste this:

document.querySelectorAll('h1,h2,h3,h4,h5,h6')
  .forEach(h => console.log(h.tagName, h.innerText.trim()))

That prints the outline in document order, read from the live page rather than the source. It takes about ten seconds and it is right most of the time.

Use innerText rather than textContent, for a reason covered further down that catches people out constantly.

What counts as a fault

FaultWhy it matters
No H1The page has no stated subject. Usually a template bug rather than a choice.
More than one H1Legal in HTML5, but it muddies what the page claims to be about. Worth a look, not an emergency.
Skipped levels, for example H2 to H4Breaks the outline for screen reader users navigating by heading. The most common real fault.
No H2s at allA page with an H1 and nothing else has no structure to read.
Headings used for stylingAn H3 chosen because it was the right size. The outline then describes the design, not the content.
Empty headingsUsually an icon or a decorative element wrapped in a heading tag. Reads as a blank entry.

Note what is not on that list: heading order by visual position. A sticky header or a fixed element can sit above the H1 on screen while sitting below it in the markup. That is not a fault, and tools that sort by visual position rather than document order will report it as one.

Where view source gives you the wrong answer

1. Text split for animation

Animation libraries that reveal a heading character by character, GSAP SplitText and its equivalents, rewrite the heading into one element per line, with each character as an inline-block child and no whitespace anywhere in the markup:

<h1><div><span>H</span><span>e</span><span>l</span>...</div></h1>

Read that with textContent and you get HelpingSunderlandBusinesses, one long welded string. Read it with innerText and you get the words back, because innerText is aware of layout and inserts the breaks the rendering actually produces.

The same thing happens with a plain <br> inside a heading. This is the single most common reason a heading tool reports mangled text.

The trade off worth knowing: innerText reflects CSS text-transform, so a heading uppercased in CSS reads back uppercase even though the markup is sentence case. There is no method that gets both right.

2. Headings that are not currently painted

Carousels, tab panels and accordions hide their inactive content with display: none. Those headings are still in the DOM, and a crawler parses the DOM, so they are part of the heading tree Google sees.

This matters more than it sounds. A hero carousel whose first slide is not the active one can hold the page's only H1. Check the painted subset and the page reports "missing H1", non deterministically, depending on where the carousel had rotated to when you looked. The page is fine. The check is wrong.

So: lint the structure against every heading in the markup, and use the visible subset only for a clickable outline you can scroll to.

Checking it without the console

Teardown is a free Chrome extension that pulls the full H1 to H6 outline of the active tab, colour codes it by level, and lints it for a missing or duplicated H1 and for skipped levels. Clicking a heading scrolls the page to it, matched by element index rather than text, so repeated headings resolve to the right one.

It handles both cases above: heading text comes from innerText, and the structure is linted against every heading in the markup rather than only the painted ones. Because it reads the tab you are on, it works on staging, on local development and behind a login.

Accessibility, briefly

Screen reader users navigate long pages by jumping between headings, which is why a skipped level is a real defect rather than a stylistic one. WCAG 2.2 covers this under 1.3.1 Info and Relationships at level A. If you only fix one thing from the fault table above, fix the skips.

Related