Give it a try

  1. To see your bookmarks bar, press the keys (Ctrl/Cmd + Shift + B) .
  2. Drag the "SpotCheck - Heading Validator" link to your bookmarks bar or bookmark folder.
  3. Open any page, then click the bookmark to run it.
  4. Click it again or click the close button in the popup to clear the results.

Example of the popup that will appear in the bottom right corner of your browser with a summary of the results.

Heading Hierarchy Validator 7 passed   0 failed

What the colors mean

Each heading is outlined and labeled pass or fail.

Pass

The heading level follows on correctly from the last one, or is the page's first h1.

Fail

The level skips one or more steps from the last passing heading, the heading has no text at all, or it's a second (or later) h1 on the page.

Try it here

Run the check against the sample markup below without leaving this page.

h1: Page title
h4: Skipped from H2
h2: Section
h3: Subsection
h1: Section

Applicable WCAG 2.2 success criteria

A correctly nested heading outline is how screen reader users understand the structure of a page and can navigate to the section they want.

  • 1.3.1
    Info and Relationships(Level A)

    Heading levels must be marked up so the structure they convey visually is also available programmatically.

  • 2.4.6
    Headings and Labels(Level AA)

    Headings need to describe the topic or purpose of the content that follows them.

  • 2.4.10
    Section Headings(Level AAA)

    At the strictest level, each section of content should be introduced with its own heading.

Read the source

The bookmarklet contains the TL;DR version of the code. Here's the readable version.

(function () {
  var NS = '__a11yHeadingOrder';
  if (window[NS] && window[NS].active) {
    window[NS].teardown();
    return;
  }

  function getRole(el) {
    var role = el.getAttribute('role');
    if (role === 'heading') {
      var lvl = parseInt(el.getAttribute('aria-level'), 10);
      return isNaN(lvl) ? 2 : lvl;
    }
    var m = el.tagName.match(/^H([1-6])$/);
    return m ? parseInt(m[1], 10) : null;
  }

  var headings = Array.prototype.filter.call(
    document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'),
    function (el) { return getRole(el) !== null; }
  );

  var records = [];
  var counts = { pass: 0, fail: 0 };
  var lastGoodLevel = 0;
  var seenH1 = false;

  function mark(el, pass, label) {
    records.push({ el: el, outline: el.style.outline, outlineOffset: el.style.outlineOffset, position: el.style.position, badge: null });
    var color = pass ? '#1a7d4f' : '#be412a';
    el.style.outline = '3px solid ' + color;
    el.style.outlineOffset = '2px';
    if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
    var badge = document.createElement('span');
    badge.textContent = label;
    badge.setAttribute('aria-hidden', 'true');
    badge.style.cssText = [
      'display:inline-block', 'margin-left:8px', 'vertical-align:middle',
      'background:' + color, 'color:#fff', 'font:600 14px monospace',
      'padding:2px 6px', 'border-radius:3px', 'white-space:nowrap'
    ].join(';');
    el.appendChild(badge);
    records[records.length - 1].badge = badge;
    counts[pass ? 'pass' : 'fail']++;
  }

  headings.forEach(function (h) {
    var level = getRole(h);
    var text = h.textContent.replace(/\s+/g, ' ').trim();

    if (!text) {
      mark(h, false, 'H' + level + ': empty heading');
      return;
    }
    if (level === 1) {
      if (seenH1) {
        mark(h, false, 'H1: multiple top-level headings');
      } else {
        mark(h, true, 'H1');
        seenH1 = true;
      }
      lastGoodLevel = 1;
      return;
    }
    if (level > lastGoodLevel + 1) {
      mark(h, false, 'H' + level + ': skipped from H' + lastGoodLevel);
    } else {
      mark(h, true, 'H' + level);
      lastGoodLevel = level;
    }
  });

  var panel = document.createElement('div');
  panel.setAttribute('data-a11y-bm-panel', 'heading-order');
  panel.setAttribute('role', 'status');
  panel.setAttribute('aria-live', 'polite');
  panel.style.cssText = [
    'position:fixed', 'bottom:16px', 'right:16px', 'z-index:2147483001',
    'background:#1b2430', 'color:#e7eaed', 'font:13px/1.5 -apple-system,sans-serif',
    'padding:12px 32px 12px 16px', 'border-radius:6px', 'box-shadow:0 4px 16px rgba(0,0,0,.3)',
    'max-width:260px'
  ].join(';');
  panel.innerHTML =
    '<strong style="display:block;margin-bottom:4px;">Heading Hierarchy Validator</strong>' +
    '<span style="color:#3fbf7f;">&#9679;</span> ' + counts.pass + ' passed &nbsp; ' +
    '<span style="color:#e06a4f;">&#9679;</span> ' + counts.fail + ' failed';
  var closeBtn = document.createElement('button');
  closeBtn.type = 'button';
  closeBtn.textContent = '\u00d7';
  closeBtn.setAttribute('aria-label', 'Close');
  closeBtn.style.cssText = [
    'position:absolute', 'top:4px', 'right:6px',
    'background:transparent', 'border:none', 'color:#e7eaed',
    'font:16px/1 -apple-system,sans-serif', 'cursor:pointer',
    'padding:2px 6px', 'border-radius:4px', 'line-height:1'
  ].join(';');
  closeBtn.addEventListener('click', function () { window[NS].teardown(); });
  panel.appendChild(closeBtn);
  document.body.appendChild(panel);

  function teardown() {
    records.forEach(function (r) {
      r.el.style.outline = r.outline;
      r.el.style.outlineOffset = r.outlineOffset;
      r.el.style.position = r.position;
      if (r.badge && r.badge.parentNode) r.badge.parentNode.removeChild(r.badge);
    });
    if (panel.parentNode) panel.parentNode.removeChild(panel);
  }

  window[NS] = {
    active: true,
    teardown: function () {
      teardown();
      window[NS].active = false;
    }
  };
})();

FYI

  • Skipping levels on the way back up (h3 to h2 to h1) is normal and doesn't fail. The rule only applies to jumping forward.
  • Elements with role="heading" and an aria-level attribute are checked the same way as native h1-h6 elements.
  • A page should only one have h1. Any additional top-level heading fails, even if the rest of the outline is correct.