Skip to content
Chapter 17Lesson 3

Semantic landmarks and the heading outline

Structure your JSX with semantic HTML landmarks and a clean heading outline so every page is navigable by screen readers and crawlers.

The last lesson handed you <body>: the root layout owns <html lang> and hands <head> to the metadata API, and everything you build lives inside that one element. So what structure goes inside it?

The Acme dashboard is a top bar with logo and navigation, a main content area, and a footer. The naive markup is a pile of <div>s, one per region. It renders, it looks right, you ship it.

Here is that same page written two ways.

<div className="page">
<div className="top-bar">
<div className="logo">Acme</div>
<div className="nav">Dashboard · Invoices · Customers</div>
</div>
<div className="content">
<div className="title">Invoices</div>
</div>
<div className="footer">© 2026 Acme, Inc.</div>
</div>

Four <div>s and nothing else. The browser draws what you’d expect, and nothing is wrong for a sighted user with a mouse, but every container is a generic box that means nothing.

The pixels are identical, but the difference matters to everyone who reads the page without your eyes. A screen reader navigates the second version as a map and hits the first as one undifferentiated wall. A crawler finds the real content in the second and guesses at it in the first. Reader mode, “skip to content,” and the browser’s outline tools all work off the second and do nothing with the first.

You’ll structure the Acme shell so its outline is legible to those machines, using two cooperating systems: landmarks (the region map) and headings (the content map). You’ll also learn to verify your work without ever opening a screen reader. The floor here, as for every real product, is WCAG 2.2 level AA, and these two systems are most of how you clear it.

You scan a page in two dimensions at once. The bar across the top is navigation, the block in the middle is content, the grey row at the bottom is the footer, and you take all of it in before reading a word, then jump your eyes wherever you want.

A screen-reader user gets the page one element at a time, as a linear stream. Moving strictly top to bottom would mean wading through the same logo, nav, and cookie banner on every page, so screen readers expose shortcuts instead: jump to the next landmark, the next heading, or the next link, or pull up a list of all headings and pick one. In NVDA and JAWS the D key cycles through landmarks and H through headings; VoiceOver offers the same moves in its rotor. The page becomes a map navigated by region and heading, the way your eyes navigate it by position and size.

That map is not your HTML directly. From the DOM the browser computes a second tree, the accessibility tree , and that is what assistive technology reads. Each node carries a role (“button,” “main region”), a name (“Save,” “Primary navigation”), and a state (“pressed,” “disabled”). Your semantic HTML is the input: a <header> becomes a banner landmark, a <nav> becomes a navigation landmark, and a <div> becomes nothing navigable at all, a styling box with no role and invisible to every jump command.

To see the tree, open Chrome DevTools, select an element in the Elements panel, and read the Accessibility pane, which shows the role, name, and state the browser computed for that node.

The DOM you wrote
<div className="page">
<header> Acme
<nav> Dashboard · Invoices
<main> Invoices
<footer> © 2026 Acme, Inc.
The accessibility tree
banner jump target
navigation jump target
main jump target
contentinfo jump target
<div> no landmark — dropped

Your semantic elements (left) become the landmarks a screen reader jumps between (right), matched here by colour. A plain <div> produces no landmark, so it’s invisible to every jump command.

Every choice in the rest of this lesson decides what shows up on the right-hand side of that picture.

Landmarks are the coarse map: the regions a screen-reader user jumps between. Seven elements produce them, and they’re easiest to learn by the three jobs they do: framing the page, marking navigation, and grouping content. You’ll see this set called “six landmarks” as often as “seven,” because two of them only become landmarks under a condition covered below.

Three elements frame every page and answer “top, middle, or bottom?” They’re the first thing a screen-reader user reaches for.

<header> is the introductory band across the top, holding your logo and primary navigation. The one page-level <header> becomes the banner landmark. A <header> is also allowed inside an <article> or <section> to introduce that piece of content, such as a blog post’s title and byline, so “one header” means one page-level header, not one total.

<main> is the unique content of this page, as opposed to the chrome repeated on every page. The rule is strict: exactly one <main> per page, never nested inside a header, nav, aside, or footer. It powers “skip to main content” and tells a search crawler where the real content begins. To decide what belongs inside, ask whether something would still be here on a different page; if yes, it’s chrome and lives outside <main>.

<footer> is closing content: copyright, footer sitemap, contact info. The one page-level <footer> becomes the contentinfo landmark. As with <header>, nested footers are fine: an <article> can carry its own <footer> with the author and publish date.

<nav> marks a region of navigation links and becomes the navigation landmark. It’s not for every list of links: a handful of related links at the bottom of an article is just a list, not a landmark. Reserve <nav> for the major navigation blocks, such as the primary nav in the header, the section links in the sidebar, and the footer sitemap.

A real app has more than one, at least a primary nav and a footer sitemap. With two <nav>s, a screen-reader user scanning the landmark list hears “navigation, navigation” with no way to tell them apart, the problem the next section solves.

These two trip people up, and they only sometimes count as landmarks.

<article> is for self-contained, independently distributable content. The test is worth memorizing: would this still make sense pasted somewhere else entirely? A blog post, a single comment, a forum reply, a product card: each stands on its own, so each is an <article>. A dashboard metric card reading “Revenue this month: $48,200” is not an article; it’s data, not a self-contained document, and makes no sense pasted elsewhere.

<section> is a generic thematic grouping, one part of the page. The test: it’s a section if it has, or should have, a heading. “Overdue invoices” with its own <h2> is a section; a group of content with no heading is just a <div>.

Here’s the catch behind the “six versus seven” count: <section> and <article> only become navigable landmarks when they have an accessible name, either a heading they point to or a literal label. Without a name they’re still meaningful grouping in the DOM, but not jumpable regions in the accessibility tree. A bare <section> is structure; a named <section> is a landmark.

<aside> is for content tangentially related to what surrounds it: a “related articles” rail beside a blog post, a tip callout next to a form, contextual help. It becomes the complementary landmark.

The most common landmark mistake lives here: the app’s left sidebar is usually not an <aside>. If it holds your section links, such as Dashboard, Invoices, and Customers, it’s navigation, so it’s a <nav>. “Sidebar” describes a position on screen, not an element; decide by what the thing is, not where it sits.

That covers the seven. Here’s the page-frame trio plus a nav as the skeleton of the Acme shell, building on the intro’s “Landmarks” tab:

<body>
<header>
<a href="/">Acme</a>
<nav>{/* primary navigation */}</nav>
</header>
<main>
<h1>Invoices</h1>
{/* sections go here */}
</main>
<footer>
<nav>{/* footer sitemap */}</nav>
</footer>
</body>

We’ll grow this skeleton across the lesson, adding names, headings, and content. For now, picture how those regions sit on the page.

<header>
<a> Acme
<nav> Dashboard · Invoices · Customers
<main>
Invoices <h1>
<section>
Overdue <h2>
<section>
This month <h2>
<footer>
<nav> Product · Pricing · Docs · Contact
© 2026 Acme, Inc.

The Acme shell as landmark regions. Every box is a decision about what that region is; a <div> would erase it from the map.

Every UI maps onto a small set of regions, and your job is to name each with the right element. Try it on real fragments of the Acme dashboard.

Each fragment is part of the Acme dashboard. Sort it into the landmark element it should use. Drag each item into the bucket it belongs to, then press Check.

header Page-level introductory band
nav A major navigation block
main The page's unique content
aside Content beside the main content
article Self-contained, distributable
footer Page-level closing content
The site logo and the top row of links
The list of dashboard section links down the left side
The sitemap links at the bottom of every page
The invoice list — the page’s primary content
A single customer testimonial that could be quoted on the marketing site
A “Pro tip” callout sitting beside the invoice form
The copyright and company address row
One comment in an invoice’s activity thread

Naming repeated landmarks with aria-label and aria-labelledby

Section titled “Naming repeated landmarks with aria-label and aria-labelledby”

The Acme shell has two <nav>s, the primary one in the header and the sitemap in the footer, and once we add <section>s for “Overdue” and “This month,” it’ll have several of those too. A screen-reader user pulls up the landmark list and hears “navigation, navigation,” or “region, region, region.” Which navigation is the primary one? Which region is billing? A landmark with no name is a door with no sign on it.

The fix is to give each repeated landmark a distinguishing name, and two attributes do it.

aria-label sets the name as literal text: you write the words, and they become the landmark’s accessible name. Use it when no visible text on the page already names the region:

<nav aria-label="Primary">{/* ... */}</nav>
<nav aria-label="Footer">{/* ... */}</nav>

Now the landmark list reads “Primary navigation, Footer navigation.”

aria-labelledby names the landmark by reference, pointing at another element’s id instead of retyping its text. Use it when a visible heading already says the name out loud:

<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
{/* ... */}
</section>

The <section>’s accessible name becomes “Overdue,” borrowed straight from the <h2>. Nothing is duplicated, and if you rename the heading the landmark’s name follows, because there’s one source of truth.

That gives you the decision rule: if a visible heading already names the region, use aria-labelledby to point at it; otherwise use aria-label with literal text. Either way, each repeated landmark gets a name that sets it apart from its siblings.

aria-label and aria-labelledby stay kebab-case in JSX, among the handful of attributes that aren’t camelCased, and their values are plain strings. The id that aria-labelledby points at is an ordinary HTML id.

These are your first taste of ARIA , and the field has a principle worth seeding now: no ARIA is better than bad ARIA. ARIA is for filling gaps native HTML leaves open, and naming an otherwise-ambiguous landmark is exactly that kind of gap: you add the one thing the element is missing without overriding what it is. A later lesson covers ARIA in full; here you need only these two naming attributes.

Here’s the Acme shell again with its landmarks named, one step at a time.

<body>
<header>
<a href="/">Acme</a>
<nav aria-label="Primary">{/* primary navigation */}</nav>
</header>
<main>
<h1>Invoices</h1>
<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
{/* overdue invoices */}
</section>
<section aria-labelledby="month-heading">
<h2 id="month-heading">This month</h2>
{/* this month's invoices */}
</section>
</main>
<footer>
<nav aria-label="Footer">{/* sitemap */}</nav>
</footer>
</body>

The header’s nav gets a literal name, so the landmark list reads “Primary navigation” instead of just “navigation.”

<body>
<header>
<a href="/">Acme</a>
<nav aria-label="Primary">{/* primary navigation */}</nav>
</header>
<main>
<h1>Invoices</h1>
<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
{/* overdue invoices */}
</section>
<section aria-labelledby="month-heading">
<h2 id="month-heading">This month</h2>
{/* this month's invoices */}
</section>
</main>
<footer>
<nav aria-label="Footer">{/* sitemap */}</nav>
</footer>
</body>

This section is named by reference: aria-labelledby points at the heading’s id, so the region’s name is “Overdue,” borrowed from the visible heading and never retyped.

<body>
<header>
<a href="/">Acme</a>
<nav aria-label="Primary">{/* primary navigation */}</nav>
</header>
<main>
<h1>Invoices</h1>
<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
{/* overdue invoices */}
</section>
<section aria-labelledby="month-heading">
<h2 id="month-heading">This month</h2>
{/* this month's invoices */}
</section>
</main>
<footer>
<nav aria-label="Footer">{/* sitemap */}</nav>
</footer>
</body>

The same pattern names “This month.” Two sections, two distinct names.

<body>
<header>
<a href="/">Acme</a>
<nav aria-label="Primary">{/* primary navigation */}</nav>
</header>
<main>
<h1>Invoices</h1>
<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
{/* overdue invoices */}
</section>
<section aria-labelledby="month-heading">
<h2 id="month-heading">This month</h2>
{/* this month's invoices */}
</section>
</main>
<footer>
<nav aria-label="Footer">{/* sitemap */}</nav>
</footer>
</body>

The footer’s nav is named too, so the two navigation landmarks read “Primary” and “Footer” and a screen-reader user can jump straight to the one they want.

1 / 1

Landmarks are one map; headings are a second, separate one, and confusing the two is a common mistake.

Landmarks answer “what regions does this page have?” Headings answer “what is the content structure within and across those regions?” A screen-reader user navigates headings as a table of contents, jumping heading to heading, independently of the landmark jumps; the two systems run in parallel. A <section> is not a heading, and neither is a <main>. A heading is a separate element, <h1> through <h6>, that lives inside those regions and describes their content.

The heading elements run <h1> through <h6>, and they obey three hard rules, testable facts rather than style preferences:

Exactly one <h1> per page. It’s the page’s primary heading; for the Acme invoices page, that’s <h1>Invoices</h1>.

Levels descend by significance, and you never skip one. <h2> is a major subsection, <h3> is a subsection of an <h2>, and so on down. Jumping from <h1> straight to <h3> with no <h2> is a real accessibility violation: it breaks the outline tree, and a screen-reader user hears the gap as “did I miss a heading?” Fix a skip by inserting the missing level or downgrading the deeper one. You may have heard that an old HTML feature auto-calculates heading levels per <section>; it doesn’t, and never reliably did. The outline is exactly the <h1><h6> levels you write.

Level is determined by outline position, not visual size. The number in <h2> is a structural claim, “this is a second-level subsection,” not “the medium-big text.” If your <h2> needs to look small or your <h3> needs to look large, that’s a separate styling decision. You never pick a heading level to get a font size.

That last rule prevents a bug you will see constantly in real codebases. Compare these two:

<div className="text-2xl font-bold">Billing</div>

Renders as big bold text, contributes nothing. To your eyes it’s a heading; to the accessibility tree it’s a <div> with no role at all. It adds nothing to the outline, so a screen-reader user jumping by heading sails right past “Billing.” There’s a visual hierarchy, but no navigable one.

The look and the level are separate knobs. Tailwind, where text-2xl comes from, is the next chapter’s subject; here it makes a single cameo to drive that point home.

Stacked up, the heading levels form a tree, a literal outline of the page. Here’s the Acme invoices page as that tree, alongside a broken one.

h1 Invoices
h2 Overdue
h2 This month
h3 Drafts
h3 Sent
A valid outline: one h1, and levels descend one step at a time. This is the table of contents a screen-reader user navigates.

You don’t need a screen reader to catch these. Chrome’s accessibility tooling and a Lighthouse audit both report heading order, and the WAVE and axe extensions flag a skipped level on the spot. Recognition is the goal here: for now, just know these tools exist and what they check.

You can now see exactly where the two maps touch. A <section> is a landmark region, and its <h2> is the node that region contributes to the heading outline. aria-labelledby, the attribute from the last section, is the wire between them: it makes the landmark borrow its name from the heading. Neither map replaces the other. A page with great landmarks and a broken heading outline is only half-navigable, and so is the reverse; a finished page needs both.

The content elements that fill the landmarks

Section titled “The content elements that fill the landmarks”

Landmarks are the regions and headings are the outline, but a region with only a heading is empty. Something has to fill it, and that’s a small set of elements where the only decision that matters is to reach for the one that carries meaning and fall back to the meaningless ones only when nothing fits.

<p> is the default block of body text. A paragraph of prose goes in a <p>, not a bare <div> and not raw text dumped into <main>; text outside a <p> is text with no structure around it.

<div> and <span> are the fallbacks, the elements with no meaning: <div> is block-level, <span> is inline, and that’s the entire difference. The rule for both is the same: reach for them only when no semantic element fits, when all you need is a box to hang styles or grouping on. A wrapper that exists purely to apply a layout is a legitimate <div>; a <div> standing where a <header>, <nav>, <main>, <section>, or heading belongs is the div soup from the start of this lesson. Now you can name what’s wrong with it: nothing breaks and nothing looks wrong, yet a whole layer of the page vanishes from the accessibility tree.

<br /> and <hr /> round out the set, both narrow and semantic. <br /> is a meaningful line break, the kind inside a postal address or a line of poetry, never visual spacing between blocks, which is a margin’s job. <hr /> is a thematic break, a real shift in topic, not a decorative divider line. Both are void elements, so they self-close in JSX, the rule from the first lesson of this chapter.

One more family belongs in these regions and is big enough for its own treatment: lists. Any sequence of related items, including the nav links inside your <nav>, belongs in a list (<ul>, <ol>, <li>), not a stack of <div>s, and the next lesson covers them in full.

Put these together with what you’ve built and a filled-in region looks like this: a heading, a paragraph, and a <div> doing honest work as a layout wrapper.

<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
<p>You have 3 overdue invoices totaling $12,400.</p>
<div className="grid">{/* invoice cards */}</div>
</section>

The <section>, <h2>, and <p> all carry meaning. The <div> carries none, and that’s correct here: all it does is hold a grid layout, not stand in for an element that should have had a name.

This is the deliverable: the complete Acme shell, semantically structured, that goes inside the <body> from the last lesson.

A page inside app/
export default function InvoicesPage() {
return (
<>
<header>
<a href="/">Acme</a>
<nav aria-label="Primary">{/* Dashboard, Invoices, Customers */}</nav>
</header>
<main>
<h1>Invoices</h1>
<section aria-labelledby="overdue-heading">
<h2 id="overdue-heading">Overdue</h2>
<p>You have 3 overdue invoices totaling $12,400.</p>
</section>
<section aria-labelledby="month-heading">
<h2 id="month-heading">This month</h2>
<p>14 invoices sent, 9 paid.</p>
</section>
</main>
<footer>
<nav aria-label="Footer">{/* sitemap links */}</nav>
<p>© 2026 Acme, Inc.</p>
</footer>
</>
);
}

It’s under-styled because there’s no Tailwind yet; that’s the next chapter. But it is semantically complete: one banner, two named navigation landmarks, one main, two named regions, one contentinfo, and a clean <h1> to <h2> outline. Landmarks live in the page or a nested layout inside app/, never up next to the <html>/<body> line, which belongs to the root layout.

Here’s the payoff of making the invisible visible: you can verify all of this without ever launching a screen reader.

  • Open DevTools → Elements → Accessibility. Click through your regions and confirm each shows the role you expected (banner, navigation, main, region, contentinfo) and that your named navs show the right accessible name. A region with no role is a <div> where an element should be.
  • Run Lighthouse, or the axe extension. Their accessibility audit flags a skipped heading level, a missing <main>, and unnamed duplicate landmarks automatically. Green means your two maps are well-formed.
  • Tab through the page. Confirm the focus order matches the reading order. This is a quick sanity check; full keyboard and focus work is a later chapter.

These structured pages enable one affordance worth knowing by name: the “skip to main content” link, a visually-hidden link placed first on the page that lets a keyboard user jump past the repeated header and nav in one keypress. It only works because you have a <main> to point at; building it belongs to a later chapter on focus management.

So far you’ve recognized good structure; now write it yourself. Below is the div-soup shell from the start of the lesson. Refactor it into semantic landmarks with a valid heading outline.

Refactor this div-based shell into semantic landmarks with a valid heading outline: one <main>, one <h1>, named navigation, and no skipped heading levels.

Preview

    A page is two cooperating maps: landmarks carve it into jumpable regions, and headings give it a single <h1> with levels descending by outline position, never by font size. Same content as the div soup you opened with, same pixels on screen, but now legible to the audience you’ll never see using it.

    Bookmark-for-later references, not required reading: the canonical sources for the landmark elements and the heading outline.