One thing that keeps coming back to me, in research, testing, and everyday conversation with colleagues and friends, is just how important headings are. For screen reader users, headings describe the relationships between sections and subsections and — where used correctly — provide both an outline and a means of navigation. Headings are infrastructure.
Applying the correct heading level is a question of context. For example, two successive headings of the same level describe two sibling sections in the document outline. Where the second of the successive headings is a higher level, it describes a subsection belonging to the first heading’s section.
HTML5 promised to automate these relationships by deferring the nesting algorithm to <section>
s (or other sectioning elements). The idea was that, by literally nesting the parent <section>
elements in the DOM, their heading levels would be adjusted in the accessibility tree for free. (The accessibility tree is the interpretation of the DOM communicated to software such as screen readers.)
<section>
<h2>Section heading</h2>
<section>
<!-- an `<h3>` to screen readers -->
<h2>Subsection heading</h2>
</section>
</section>
Unfortunately, to date, no major browser vendor has meaningfully implemented this so-called “document outline algorithm”. And if the browser doesn’t expose it, screen readers can’t communicate it. The last code example just wouldn’t work.
Those of us interested in creating accessible document outlines are therefore stuck choosing heading levels (<h1>
to <h6>
) regardless of the <section>
s we might be employing.
<section>
<h2>Section heading</h2>
<section>
<h3>Subsection heading</h3>
</section>
</section>
This poses a particular problem when developing pattern libraries for design systems. While individual patterns/components within a design system can — and should — use headings, it’s difficult to know which heading levels they should take. As isolated modules within a pattern library, the context of the component within a page is indeterminate. For reusable components the context will change, along with the level required.
The level prop
APIs like React’s and Vue’s let us manually include properties (or ‘props’) for our components on instantiation. These allow us to apply certain settings to the component’s instance, to be used internally.
By supporting a level
prop, we can allow authors to adjust the heading level of the component at the time of instantiation, when they are aware of its surrounding context. This is how it would be applied on the outer component:
<MyComponent level="3"></MyComponent>
Internally, we need to take the prescribed level and use it to augment the component’s principle heading. There are a couple of ways to do this. The first way would be to employ some logic that chooses the correct heading element (<h1>
to <h6>
) for us. In React, we can use a component as a variable:
render() {
const H = 'h' + this.props.level;
return (
<div>
<H>A heading of some level</H>
<p>Lorem ipsum etc.</p>
</div>
);
}
This will quickly become unwieldy when maintaining a structure of multiple headings within the component (see the ensuing section). Instead, we can change the heading element’s level in the accessibility tree directly, via the aria-level
property.
<h2 aria-level={props.level ? props.level : null}></h2>
Two things to note:
- An
<h2>
element is used as a "sensible default". All first-tier subsections following the page's principle<h1>
heading would be of the second (<h2>
) level. Where thelevel
prop is not provided,aria-level
is not applied and the implicit second level acts as a default (thenull
value means the attribute isn’t rendered). - The
<h2>
already has the implicit heading role, so the use ofrole="heading"
would be redundant. Had we used a<div>
to define our heading,role="heading"
would be needed alongsidearia-level
. This is not recommended, because the support is relatively scant. At least wherearia-level
is not supported, we still have an<h2>
heading — even if it's not the correct level. Headings, of any level, can be navigated between in screen readers like NVDA or JAWS using the h key.
Multiple headings
In some cases, the subtree that makes up our component may include multiple headings, describing a microstructure within the document. Hard coding each level via its own prop would be verbose, and prone to error on the part of the author.
<!-- don't do this -->
<MyComponent mainHeadingLevel="3" subHeadingLevel="4"></MyComponent>
Instead, we can maintain the same structure by applying some simple arithmetic.
<h2 aria-level={props.level ? props.level : null}>Heading text</h2><p>Lorem ipsum.</p><h3 aria-level={props.level ? props.level + 1 : null}>Subheading text</h3>
<p>Lorem ipsum.</p><h4 aria-level={props.level ? props.level + 2 : null}>Nested subheading text</h4>
<p>Lorem ipsum.</p>
Now the author still needs only to apply one level
at the time of instantiation (if needed!) and the whole structure shifts accordingly. Simple, but effective.
Complete automation
Did you know that an <h>
element was originally mooted as early as 1991, and would automate heading levels in the way the fabled “Document Outline Algorithm” is supposed to? In fact, it’s still being discussed.
As the eminent Sophie Alpert demonstrated to me, it is possible to emulate this automated outlining behavior using React’s relatively new Context API.
The beauty of Context is that you can — to quote the docs — “pass data through the component tree without having to pass props down manually at every level”. In practice, this means we can adjust the value of level
simply by nesting components.
First we have to initialize the context.
const Level = React.createContext(2);
Note the 2
which sets the default level, as in previous examples. Now we just need to set up the Section
and H
components.
function Section(props) {
return (
<Level.Consumer>{level =>
<Level.Provider value={level + 1}>
{props.children}
</Level.Provider>
}</Level.Consumer>
);
}function H(props) {
return (
<Level.Consumer>{level => {
const Heading = 'h' + Math.min(level, 6);
return <Heading {...props} />;
}}</Level.Consumer>
);
}
The Section
consumes the level
value for that nesting level (via Level.Consumer
) then adjusts the level for any children(level + 1
).
Where any H
element consumes the contextual level
, Math.min
ensures that no headings of a greater level than 6
are rendered. An <h7>
or <h14>
would not be interpreted as a heading by the browser, causing parsing and —therefore—accessibility shortcomings.
Aside from the capitalization (and React!) we can now structure something very close to what TimBL originally imagined:
function Document(props) {
return (
<div>
<h1>Automating Heading Depth</h1>
<Section>
<H>Level 2</H>
<H>Sibling Level 2</H>
<Section>
<H>Level 3</H>
</Section>
</Section>
<H>Level 2</H>
<p>Lorem ipsum dolor sit etc.</p>
</div>
);
}
Any component that uses Level.Provider
and Level.Consumer
can be made to respect the context-aware outline. For components that wrap content but cannot be considered semantic subsections, the level can just be passed through and reused as is — not incremented.
Styling
How you apply the styling depends on your strategy. In most cases, the font-size
of the heading should reflect its position in the hierarchy, with the <h1>
as the largest, or most prominent.
Using Context, this is done automatically, by rendering the different heading elements. Using the aria-level
technique, you would need to couple the elements and attributes according to level:
h1, [aria-level="1"] { font-size: 3rem }h2, [aria-level="2"] { font-size: 2.25rem }h3, [aria-level="3"] { font-size: 1.75rem }/* etc */
Where you wish the visual appearance of the component’s heading to be unaffected by context, while still maintaining an accessible hierarchy, you might apply a class.
<H className="text-large">Heading text</H>
Using a library like Styled Components, you would create new headings based on the semantic H
component, and style them directly. You just need to extend H
:
const Heading = H.extend`
font-size: 2rem;
`;
Headings still matter
Some web application developers have a habit of dispensing with anything they consider “from an era of simple documents”. It’s true that headings originate in a tradition of marking up static, prosaic documents like those you might write in MS Word or similar. But to think a sound heading structure is not necessary in an application interface is to misapprehend what headings really are.
Headings are labels for sections (or ‘areas’ if you prefer) that make up an interface. They can label a section of information, or a set of interactive controls. It’s all the same. Labeling the sections of your interface is just as important as labeling your individual controls. Without labels, folks simply don’t know what anything is for.
For screen reader users, headings are not just labels but a ‘bypass’ mechanism that allows them to navigate between different areas. Without headings, they have to step through every single element in turn, to get from one place to another. Arduous and off-putting.
Keyboard users not running a screen reader supporting these shortcuts may find their experience pretty arduous — especially where there are huge numbers of interactive controls to step through. Fortunately, a browser extension from Matt Atkinson is available to help. It provides shortcuts not to headings, but landmark elements (<header>
, <main>
, <footer>
etc) but headings support is also being considered. The best document (and application!) structures include both landmarks and headings. Belt and braces.
For more about inclusive design and design systems see https://inclusive-components.design/ and follow Inclusive Components on Twitter.