core accessibility principles for modern frameworks

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 Relationships
  • 2.4.6 Headings and Labels
  • 2.4.10 Section Headings
A correct outline compared with one broken by composition Two heading trees for the same page. The correct one runs h1 page title, h2 section, h3 card title, h3 card title, h2 next section. The broken one has the same visual result but the card hard-codes h4, producing a jump from h2 straight to h4 and back, which reads as a missing level in the headings rotor. A note explains that the card cannot know its depth, so the level has to come from its context. Correct outline h1 · Accessibility guides h2 · Core principles h3 · Focus management h3 · Keyboard patterns h2 · Testing Reads as a table of contents. Broken by a hard-coded level h1 · Accessibility guides h2 · Core principles h4 · Focus management h4 · Keyboard patterns h2 · Testing A level is missing; the rotor implies content that is not there. The card renders identically in both. Only the level differs — and only the outline notices.

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 level prop typed as 2 | 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 size prop or a variant class, so a small h2 and a large h4 are 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 SectionContext holding 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 h6 and fall back to a role="heading" with aria-level beyond that — though needing more than six levels usually signals a structural problem.
  • Keep an escape hatch: an explicit level prop 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.

What belongs in the h1 on each kind of page Three page types with the right h1 for each. An article's h1 is the article title, matching the document title without the site suffix. A section index page's h1 names the section, not the site. An application view's h1 names the current view, such as Invoices, rather than the product name. A note adds that repeating the site name in every h1 makes the headings rotor useless across pages. Article page h1 = the article title, matching <title> without the site suffix Section index h1 = the section name, not the site name Application view h1 = the current view, such as "Invoices" — never the product name on every screen

Implementation Guidelines:

  • Render the h1 in the page template, not in a shared layout that appears on every view — a site-name h1 repeated everywhere carries no information.
  • Match the h1 to the <title> element's first segment, so the announced page name and the first heading agree.
  • Do not hide the h1 visually 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 h1 on 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-order and 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.
Separating heading level from heading size A grid showing that level and size are independent choices. A large h4 is valid and useful for a prominent card title deep in the outline. A small h2 is valid for a quiet section label near the top. Choosing the tag by size produces the two invalid combinations shown crossed out: skipping a level to get a smaller default, and promoting a level to get a bigger one. Large h4 — valid A prominent card title deep in the outline. Small h2 — valid A quiet section label near the top of the page. h4 chosen for its size — invalid Skips a level and breaks the outline. h2 chosen for its size — invalid Promotes a subsection into a top-level entry.

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-one and empty-heading rules 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.title with the h1 on 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 h1 on 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.