Fixing Heading Order Problems in Markdown Content
Screen reader users navigate long pages by headings more than by anything else. NVDA's H key jumps to the next heading, the numbers 1 to 6 jump by level, and the elements list shows the page as an outline. That outline is only as good as the heading levels in the content. When a writer chooses #### because the H2 looked too large, or starts a file with # Title under a layout that already renders the title, the outline lies: a skipped level suggests a missing section, and two H1s suggest two pages.
On the reference site from Accessibility for Static Sites, the first content lint found 173 skipped levels and 22 pages with two H1s across 1,140 pages. This guide fixes them in bulk with a codemod, adds a lint that blocks new ones, and covers the template and component cases a Markdown lint cannot see.
Prerequisites
- A Markdown pipeline that accepts remark or markdown-it plugins, or Hugo render hooks.
- Write access to the content repository and a CI step for pull requests.
- Agreement with writers on the convention: the layout renders the H1; Markdown starts at H2.
What the Audit Found
A remark script listed every heading with its level, file and line, then flagged three patterns:
| Pattern | Example | Count |
|---|---|---|
| Skipped level going deeper | H2 followed by H4 | 173 |
| Second H1 from Markdown | # Getting started under a layout H1 | 22 |
| Empty or symbol-only heading | ## or ### --- | 9 |
| Heading used for a callout | #### Note before a paragraph | 61 (subset of the 173) |
| Bold paragraph used as a heading | **Configuration** on its own line | 118 |
The last row is the opposite problem: text that looks like a heading but is not one, so it is invisible in the outline. It is not a WCAG failure in every case, but on reference pages where the bold lines introduced sections, they were converted to real headings.
Step 1: Remove the Duplicate H1s
Starlight, VitePress and Docusaurus render the frontmatter title as the H1. Twenty-two files also started with # Title, a habit carried over from a previous generator. A one-line check finds them and a small script removes the first line when it matches the frontmatter title:
grep -rlE '^# ' src/content/docs | while read f; do
title=$(sed -n 's/^title: *//p' "$f" | head -1 | tr -d "'\"")
first=$(grep -m1 -E '^# ' "$f" | sed 's/^# //')
[ "$title" = "$first" ] && sed -i "0,/^# /{/^# /d}" "$f"
done
Where the Markdown H1 differed from the title, a writer chose which to keep. Three of those pages had a better H1 in the Markdown than in the frontmatter, and the frontmatter was updated instead.
Step 2: Repair Skipped Levels With a Codemod
Most skipped levels follow a pattern: an H2 section with H4 subsections, where H3 was skipped because it looked too big. Promoting each heading to at most one level below its parent fixes the outline without changing meaning:
// scripts/fix-heading-levels.mjs
import { remark } from 'remark';
import { visit } from 'unist-util-visit';
import remarkFrontmatter from 'remark-frontmatter';
export function fixLevels() {
return (tree) => {
let prev = 1; // layout renders the H1
visit(tree, 'heading', (node) => {
if (node.depth > prev + 1) node.depth = prev + 1;
prev = node.depth;
});
};
}
// run over every file with remark().use(remarkFrontmatter).use(fixLevels)
The codemod changed 173 headings in 211 files' worth of diffs. The 61 callout headings were then converted by hand to the site's :::note aside syntax, because a callout is not a section and should not be in the outline at all. Reviewing the diff took one person about three hours.
Step 3: Make Size a Styling Decision
Writers picked deeper levels because the default H3 was 1.5 rem and looked heavy inside short sections. The type scale was adjusted so H3 is 1.2 rem and H4 1.05 rem with a heavier weight, which removed the visual reason to skip. For the rare heading that genuinely needs a smaller look, Starlight and Astro Markdown support {.class} attributes through a remark plugin, and Hugo supports {.class} on headings natively with markup.goldmark.parser.attribute.title = true.
Step 4: Lint Every Pull Request
The lint runs the same depth rule and two others, failing on any violation with a message that names the fix:
// plugins/remark-heading-lint.mjs
export default function headingLint() {
return (tree, file) => {
let prev = 1;
visit(tree, 'heading', (node) => {
const text = toString(node).trim();
if (node.depth === 1) file.fail('Do not use # in Markdown; the layout renders the page title as H1', node);
if (!text) file.fail('Empty heading', node);
if (node.depth > prev + 1)
file.fail(`Heading jumps from H${prev} to H${node.depth}; use H${prev + 1} and style it with CSS if it looks too large`, node);
prev = node.depth;
});
};
}
file.fail throws, so the build stops with the file and line. For Eleventy, the same logic goes in a markdown-it core rule that walks heading_open tokens; for Hugo, a heading render hook can track the previous level in a page scratch variable and call errorf.
Step 5: Check the Rendered HTML Too
Components can add headings the Markdown lint never sees. The reference site had two: a "Related" component that rendered an H4 at the end of every page, after an H2, and an API parameter table component that used H5 for each parameter name. A post-build check walks every page's headings in document order inside <main> and applies the same rule:
const hs = [...main.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => +h.tagName[1]);
hs.reduce((prev, d, i) => { if (d > prev + 1) problems.push(`${file}: H${prev} → H${d} at heading ${i}`); return d; }, 0);
The Related component became an H2, and the parameter names became <dt> elements in a definition list, which is what they were. axe's heading-order rule covers the same ground on the pages it scans, but this check covers all 1,140 pages in two seconds.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Skipped heading levels | 173 | 0 | remark lint |
| Pages with two H1s | 22 | 0 | HTML check |
axe heading-order (60 pages) | 47 elements | 0 | axe-core 4.10 |
| Bold lines converted to headings | — | 118 | Manual review |
| Lint + HTML check time | — | 0.3 s + 2 s | CI logs |
| PRs blocked in 8 weeks | — | 16, all fixed in the PR | CI history |
A screen reader user who tested the API section before and after described the "after" outline as the first time the API reference could be navigated "by its headings instead of by searching for words".
Pitfalls & Rollback
- Fixing the outline by changing CSS only. Size and level are separate; fix both.
- Turning callouts into deeper headings. Use an aside component instead.
- Linting Markdown but not components. Run the HTML check too.
- Running the codemod without review. Most changes are mechanical, but some deeper headings were meant to be sections of their own.
- Rollback: the codemod is a single commit that can be reverted; the lint can be set to warn instead of fail with one option.
Conclusion
Heading levels are the map screen reader users navigate by, and Markdown makes them easy to choose for size instead of structure. On the reference site a codemod repaired 173 skipped levels, a two-line rule removed 22 duplicate H1s, and a lint plus an HTML check keep new problems out of both content and components.
FAQ
Is a skipped heading level a WCAG failure?
Not strictly. WCAG 1.3.1 requires that structure conveyed visually is available programmatically, and a skipped level is usually reported by axe as a best-practice issue rather than a violation. In practice it misleads screen reader users, who infer a missing section when a level is skipped, so it is worth fixing everywhere and enforcing for new content.
Should the Markdown title be an H1 if the layout already renders one?
No. Most documentation layouts render the frontmatter title as the page H1, so a Markdown file that starts with a single hash produces a second H1. Either remove the heading from the Markdown or configure the layout not to render the title, but not both. One H1 per page is the convention screen reader users rely on.
How do I make a heading smaller without changing its level?
Use CSS. Heading level is structure and size is presentation, so if a section heading looks too large, style it with a class or adjust the type scale rather than picking a deeper level. In Markdown, most generators support attribute syntax or a component for adding a class when one heading genuinely needs a different look.
Do headings inside components and MDX count?
Yes. Screen readers see the rendered HTML, so a component that renders an H4 inside a page section affects the outline just like a Markdown heading. A check on the built HTML catches these; a Markdown-only lint does not.
Related
- Parent: Accessibility for Static Sites — content issues and how to lint them.
- Enforcing Alt Text for Markdown Images at Build Time — the companion content lint.
- Skip Links and Landmarks in SSG Layouts — the other structure screen readers navigate by.
- Content Linting with markdownlint — where content checks run.
- Fixing CLS From Dynamic Table of Contents Widgets — a table of contents built from the same headings.
Filed underFixes