Accessible Headings & Document Outline in Components
Screen reader users navigate long pages by heading, and the heading list they get is generated from source order and level. That list is only useful if the levels are correct — and in a component codebase they very often are not, because the component that renders <h3> has no idea how deeply it has been nested. A <Card> hard-coding <h3> reads fine on the page it was designed for and produces a jump from h2 to h4 on the next one.
This guide covers the patterns that keep the outline correct through composition. It extends Semantic HTML vs ARIA in Component Trees.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships2.4.6 Headings and Labels2.4.10 Section Headings
Prerequisites
- A component library where shared components render headings (cards, panels, accordions, dialogs).
- A page template that establishes the top of the outline — usually one
<h1>per view.
Take the Level as a Prop
The simplest fix is also the most explicit: the component that knows the context passes the level down.
Implementation Guidelines:
- Give every heading-rendering component a
levelprop typed as2 | 3 | 4 | 5 | 6, and require it. - Do not default it. A default is a guess, and the guess is wrong on the second page the component appears on.
- Keep the visual size independent of the level: a
sizeprop or a variant class, so a smallh2and a largeh4are both expressible. - Never render a heading level based on CSS or on the component's own nesting in the DOM — neither is visible to the accessibility tree.
type HeadingProps = {
level: 2 | 3 | 4 | 5 | 6;
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
};
export function Heading({ level, size = 'md', children }: HeadingProps) {
const Tag = `h${level}` as const;
return <Tag className={`heading heading--${size}`}>{children}</Tag>;
}
Or Derive It From Context
Passing level through three layers of composition gets tedious. A context that tracks the current depth removes the prop drilling while keeping the outline correct.
Implementation Guidelines:
- Provide a
SectionContextholding the current level; a<Section>component increments it for its children. - Render the heading at the context's level, so a card dropped one section deeper automatically renders one level lower.
- Cap at
h6and fall back to arole="heading"witharia-levelbeyond that — though needing more than six levels usually signals a structural problem. - Keep an escape hatch: an explicit
levelprop that overrides the context for the rare case that needs it.
const LevelContext = createContext<2 | 3 | 4 | 5 | 6>(2);
export function Section({ children }: { children: React.ReactNode }) {
const level = useContext(LevelContext);
const next = Math.min(level + 1, 6) as 2 | 3 | 4 | 5 | 6;
return (
<section>
<LevelContext.Provider value={next}>{children}</LevelContext.Provider>
</section>
);
}
export function SectionHeading({ children }: { children: React.ReactNode }) {
const level = useContext(LevelContext);
const Tag = `h${level}` as const;
// A11y rationale: 1.3.1 Info and Relationships — the level reflects real
// nesting, so the outline stays sequential however components are composed.
return <Tag>{children}</Tag>;
}
One h1, and What It Should Say
Every view needs exactly one h1, and it should name the view rather than the site.
Implementation Guidelines:
- Render the
h1in the page template, not in a shared layout that appears on every view — a site-nameh1repeated everywhere carries no information. - Match the
h1to the<title>element's first segment, so the announced page name and the first heading agree. - Do not hide the
h1visually unless the visual design genuinely has no title; a visually hidden heading is a hint that something is missing on screen. - In a single-page app, update the
h1on route change and move focus to it — see handling focus restoration after dynamic route changes.
Headings Are Not for Styling
The other half of the problem runs the opposite way: an element that looks like a heading but is not one, or a <h3> chosen because it was the right size.
Implementation Guidelines:
- Choose the level from position in the outline, then adjust the size with CSS. Never the reverse.
- Do not use headings for short labels inside a component — a card's metadata line is not a heading, it is text.
- Do not skip levels to get a smaller default size; skipping breaks
heading-orderand the outline. - Where a visual heading genuinely is not part of the outline (a decorative pull quote), render it as a
<p>with heading styles.
How to Verify
- Rotor read. Open the headings list in a screen reader and read it top to bottom. It should sound like a table of contents with no gaps.
- Automated check. axe's
heading-order,page-has-heading-oneandempty-headingrules catch most structural mistakes — see Automated Accessibility Testing with axe-core. - Composition test. Render the same card at two depths in a test and assert the heading level differs. That is the regression the context pattern exists to prevent.
- Title agreement. Compare
document.titlewith theh1on each route; a mismatch usually means one of them is stale. - Outline extraction. Dump every heading with its level in a Playwright test and snapshot it, so an accidental reordering shows up as a diff.
Common Accessibility Mistakes
- Hard-coded levels in shared components. Correct on the page they were designed for, wrong everywhere else.
- Skipping levels for visual size. Produces a rotor that implies missing content between the levels.
- More than one
h1. Ambiguous page title, and a rotor that offers two starting points. - A site-name
h1on every page. Every page announces the same first heading, which tells the user nothing about where they are. - Headings used as labels. A four-word "heading" for a metadata row clutters the outline with entries that lead nowhere.
- Empty headings. Usually a heading whose content is rendered conditionally; the rotor entry stays and announces nothing.
Conclusion
The heading outline is a navigation surface, and in a component codebase it can only stay correct if the level comes from context rather than from the component. Take the level as a required prop, or derive it from a section context that increments with real nesting, and keep size a separate concern entirely. Then enforce it: heading-order in the automated scan, a composition test that renders the same component at two depths, and a rotor read before shipping anything with a new page template.
A final note on ownership: the outline is a property of the page, but it is produced by components that were written without knowing which page they would land on. That mismatch is the whole problem, and it is why the fix has to live in the API rather than in a style guide. A required level prop or a section context makes the correct outline the path of least resistance; a documented convention makes it the path that a busy engineer skips at four o'clock on a Friday. Choose the mechanism that does not depend on anyone remembering.
Frequently Asked Questions
Does the HTML5 outline algorithm handle this automatically?
No. The sectioning-content outline algorithm was never implemented by browsers or assistive technology and has been removed from the specification. Nesting <section> elements does not change how <h3> is announced — the number in the tag is what counts.
Is it acceptable to use role="heading" with aria-level?
It works and it should be a last resort. Native heading elements come with the level, the role and default styling hooks; the ARIA equivalent needs all three supplied by hand and is easier to get wrong. Reserve it for levels beyond h6, which are rare enough to be a design smell.
How should headings work inside a dialog?
The dialog's title should be a heading and should also be the dialog's accessible name, wired with aria-labelledby. Level-wise, treat the dialog as a fresh context: an h2 inside a dialog is fine even if the page's outline is deeper, because the dialog is a separate region.
What about visually hidden headings for landmarks?
They are a legitimate tool — a hidden <h2>Applied filters</h2> gives a rotor entry for a region that has no visible title. Use them sparingly; if a region needs a hidden heading to make sense, consider whether a visible one would help everyone.
Should the level prop be required or optional? Required. An optional level gets a default, the default is right for the first use, and every subsequent use silently inherits the wrong one. A required prop turns the question into a compile error at the moment the component is placed.
Related guides
- Semantic HTML vs ARIA in Component Trees — the parent guide on element semantics.
- Choosing Between Button and Link in Component APIs — the same principle for interactive elements.
- Accessible Navigation & Landmark Structure — landmarks and headings as two navigation layers.
- Accessible Breadcrumbs & In-Page Anchors — the table of contents generated from this outline.