# Headings chosen by size, not by structure

Source: https://easeweb.dev/learn/heading-structure
Topics: Page structure > Headings and labels describe the content (WCAG 1.3.1, 2.4.6)
The fix: Mark each section title as a heading at the level its place in the outline calls for, and set its size with a class. Either change the styled divs to h2 and h3 elements, or, where the element can't change, add role="heading" and aria-level to them. Either way, remove the empty heading and reword the one that says only “More”.
Test with: screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html https://www.w3.org/WAI/WCAG22/Techniques/html/H42 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA12 https://www.w3.org/WAI/WCAG22/Techniques/failures/F2 https://www.w3.org/WAI/tutorials/page-structure/headings/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/Heading_Elements https://dequeuniversity.com/rules/axe/4.10/heading-order https://dequeuniversity.com/rules/axe/4.10/empty-heading

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A recipe page for lentil soup has a large title, a short introduction and a thin rule. Below the rule are two sections, Ingredients and Method, each under a bold title. A smaller bold title, Tips, closes the method, and a row of related recipe cards ends the page under the title More. To a sighted reader the outline is plain. A screen reader user who wants the method does what most people do on a long page: they open the headings list, or press H to jump from heading to heading. The list reads "Lentil soup, heading level 1", "heading level 2", "Tips, heading level 5" and "More, heading level 2". Ingredients and Method, the two sections the page exists for, are missing. One entry has no text, and the last doesn't say more of what.

## Why it fails

Each title was marked up for how it should look, not for what it is. 1.3.1 needs the structure people see to be in the code too: here, which lines are section titles and which section sits inside which. 2.4.6 needs headings to describe what they introduce. The page breaks that in four places:

- Ingredients and Method are `div` elements with a class that makes them large and bold. They look like headings and aren't, which is WCAG failure F2.
- Tips is an `h5` because the design's h5 was the right size. It belongs to Method, a level 2 section, so its level puts it three levels deeper than it is.
- The rule under the introduction is an empty `h2` with a top border, so the headings list has a stop with no text.
- The related recipes are headed More. Beside the cards that reads well enough, but in a list of headings it says nothing about what follows.

## Who is affected

Nothing on the screen is wrong: size, weight and spacing all show the outline. The failure is in the markup, so it reaches the people who get a page's structure from the code instead of from how it looks.

## What automation and AI miss

axe reports the empty `h2` (`empty-heading`) and the jump from h1 to h5 (`heading-order`), but classes both as best practice, so many audits leave them out. Neither rule can see a `div` that looks like a heading, so the two missing sections, the largest part of the failure, pass every scan. Fixing only what the scanner reports can make things worse: changing the h5 to an h2 clears `heading-order` and puts Tips on the same level as Method. A quick AI fix often goes the other way and turns every bold line into a heading, the serving size and cooking time included, which fills the list with stops nobody wants. Whether each line is a heading, at which level, and whether its words describe its section, are things a person has to judge against the page.

## Before: titles marked up for their look

```html
<h1>Lentil soup</h1>
<p>A thick, warming soup for weeknights.</p>
<h2 class="divider"></h2>

<div class="section-title">Ingredients</div>
<ul>…</ul>

<div class="section-title">Method</div>
<ol>…</ol>
<h5>Tips</h5>
<p>…</p>

<h2>More</h2>
<ul class="cards">…Tomato soup, Pea soup, Leek and potato soup…</ul>
```

The two main sections have no heading, the only level 2 heading before them is empty, Tips claims a depth its place doesn't have, and More doesn't say what it introduces.

## After: heading elements at the right level

```html
<h1>Lentil soup</h1>
<p>A thick, warming soup for weeknights.</p>

<h2 class="section-title divider">Ingredients</h2>
<ul>…</ul>

<h2 class="section-title">Method</h2>
<ol>…</ol>
<h3 class="title-small">Tips</h3>
<p>…</p>

<h2 class="section-title">More soup recipes</h2>
<ul class="cards">…Tomato soup, Pea soup, Leek and potato soup…</ul>
```

```css
.section-title { font-size: 1.375rem; font-weight: 700; }
.title-small { font-size: 1rem; font-weight: 700; }
.divider { border-top: 1px solid #d4d4d4; padding-top: 1rem; }
```

Ingredients, Method and More soup recipes are level 2 headings under the page title, and Tips is a level 3 heading inside Method, so the headings list now reads like the page. The classes keep every title exactly the size it was, because the size never depended on the element. The rule is a border on the first section title, so it no longer adds an empty stop.

## After: role="heading" where the element can't change

```html
<h1>Lentil soup</h1>
<p>A thick, warming soup for weeknights.</p>

<div class="section-title divider" role="heading" aria-level="2">Ingredients</div>
<ul>…</ul>

<div class="section-title" role="heading" aria-level="2">Method</div>
<ol>…</ol>
<h3 class="title-small">Tips</h3>
<p>…</p>

<h2 class="section-title">More soup recipes</h2>
<ul class="cards">…Tomato soup, Pea soup, Leek and potato soup…</ul>
```

The CSS is the same as above. The two titles stay `div` elements and gain the heading role and a level, which is WCAG technique ARIA12, so screen readers list them as level 2 headings, exactly as with `h2`. Tips, the rule and More are fixed in the same way as with heading elements, since those already were headings or can be removed. Always set `aria-level`: without it the level defaults to 2, which is right here only by chance. Tools that look for `h1` to `h6` in the HTML rather than in the accessibility tree, such as many table-of-contents scripts, still see a `div`.

## Which option to use

Both put every title in the headings list at the right level and meet 1.3.1. Use heading elements wherever you can change the markup: they do the same job with no attributes to keep in step, and every tool that reads the page treats them as headings, not only the ones that read ARIA. Keep `role="heading"` for a title whose element you can't change, such as one a third-party widget renders, and switch it to a heading element when you can.

## Implementation decisions

Most heading mistakes come from tying a heading's level to its size. Three places usually do that:

- A design system's heading component. Give it a level and a size as separate props, for example `<Heading level={3} size="small">`, so nobody has to pick the wrong level to get the right look.
- A CMS editor. Editors choose "Heading 5" from the toolbar because it is small. Offer the sizes as styles and limit the levels to the ones a body of text can use, usually h2 to h4.
- Cards and other reused components. A card's title is an h3 under an h2 section and an h2 on a page without one, so take its level from where it is placed, not from the component.

Not every bold line is a heading. A heading introduces the content after it; the serving size, a price or a pull quote doesn't, so leave those as text, however large they are drawn. Removing a heading that was only there for its look is as much a part of the fix as adding the missing ones.

Use CSS for lines and space. A border or margin draws the same rule without adding anything to the headings list. Use an `hr` only where the rule marks a real change of topic, since screen readers announce it as a separator.

Short headings are fine. Tips is enough under Method, because its level says whose tips they are. More needed its extra words because it sits at level 2, beside Ingredients and Method, where nothing around it says more of what.

WCAG doesn't require one `h1` or forbid every skipped level. Keep one `h1` that names the page's content, and step down one level at a time, so the levels never claim a relationship the page doesn't show.

## Verify the fix

1. List the page's headings, from the accessibility tree in the browser's developer tools or a headings outline extension, and hold the list against the titles you can see. Every section title is there, in the same order, nothing that isn't a title is, and no heading is empty.
2. Check each level: every heading sits one level below the section it belongs to, and none is more than one level deeper than the heading before it.
3. With a screen reader, open the headings list (in NVDA, Insert + F7 and choose Headings; in VoiceOver, the rotor) and press H to move from heading to heading in NVDA or JAWS. Each stop lands at the start of its section and is announced with its level.
4. Read only the headings list, without the page around it. Each heading says what its section holds.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever a template, a shared heading component or the CMS editor's styles change.

## Limits

This is a starting pattern, not a guarantee for every application. Class names, colours and the `…` in the lists are placeholders. Screen readers word the headings list differently, so listen for the titles and levels rather than the exact phrasing. This guide covers whether titles are headings, their levels and their words. It doesn't cover whether long content has enough headings in the first place, or the labels on form fields. Test the complete journey with your target assistive technology.
