Managing Heading Levels In Design Systems

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.

alt text = in the image, there are two boxes one after the other, marked as h2 level siblings. Inside the second box is a third box marked as an h3 level subsection.

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.

alt text = In the image, a component is shown to be reusable in different contexts of a page. Depending on the context, the component would have to have an h2 or an h3 heading level.

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 the level prop is not provided, aria-level is not applied and the implicit second level acts as a default (the null value means the attribute isn’t rendered).
  • The <h2> already has the implicit heading role, so the use of role="heading" would be redundant. Had we used a <div> to define our heading, role="heading" would be needed alongside aria-level. This is not recommended, because the support is relatively scant. At least where aria-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.