Sergio Xalambrí

Keep Heading Levels Consistent with React Context

When building for the web one of the most common elements are the heading tags (h1 to h6) which are intended to define as the name implies the heading of each section of your web. In a document-like website (e.g. a blog) is not too hard to keep consistency and use them in the correct order, mostly because they are static pages. However, when building an application-like website (or WebApp) it becomes harder, specially when you are using a component system to build your UI.

Nevertheless, keeping consistency in your heading levels is important for the accesibility of your app to help visually impaired users navigate your app and understand it.

The hardest part I have found is to know what heading level to use. Is this card an h3 or h4? What about this other one? This is even harder if your UI components can be used in different parts and with different nesting levels, in which case you need to increase the heading level.

tl;dr see the code https://codesandbox.io/s/keep-heading-levels-consistent-with-react-context-90bud

A Heading component

One thing you can do first is to create a Heading component which abstract you this, something like this one:

type HeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
  level: 1 | 2 | 3 | 4 | 5 | 6;
};

function Heading({ level, ...props }: HeadingProps) {
  // We use createElement directyl so we don't need to define each possible tag based on the level
  return React.createElement(`h${level}`, props);
}

And then we could use it with

<Heading level={1}>Timeline</Heading>

More than 6 levels

You probably know the heading level go from 1 to 6, as we defined above, however it's possible to use higher levels thank to the ARIA attributes, let's update our Heading to support any level.

type HeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
  level: number;
};

function Heading({ level, ...props }: HeadingProps) {
  if (typeof level === "number" && level <= 0) {
    throw new Error(
      "The level of a Heading must be a positive value greater than zero."
    );
  }
  if (level <= 6) return React.createElement(`h${level}`, props);
  return React.createElement("div", {
    ...props,
    role: "heading",
    "aria-level": level,
  });
}

And then we could use it with

<Heading level={100}>Some super nested title</Heading>

While we are using a div tag we use the role attribute to tell the browser and screen readers that element is actually a heading, then we use the aria-level attribute to define the heading level. Doing this we can use any level we want, even 100 as in the example or higher numbers.

Defining the level automatically

Now we have a component to abstract us the creating of a heading, and support any level. But we didn't solve, yet, our problem of what level to use. Let's solve this.

First we need to know that there is a role called region, this is the actual role of a <section /> if it has a heading inside, so we know that a region has a heading, so we could say a nested region should have a higher heading, right? This way we could create our application as a region with an heading level 1 and more regions inside. Let's build a simple region component.

function Region(props: React.HTMLAttributes<HTMLDivElement>) {
  return <section {...props} />;
}

Note: The role is not actually required.

Enter Context

Context is a way we could inject data from a parent component to a nested component without manually passing props. We could use that to define the level of the heading in the Region and make our Heading get the level and use it. Let's create this Context.

const HeadingLevelContext = React.createContext<number>(0);

This Context will start with a value of 0 which is not a valid heading level, this will let us know we are not using our code correctly because if a Heading receives this level it means it's not inside a Region.

function Heading({ level, ...props }: HeadingProps) {
  const headingLevel = React.useContext(HeadingLevelContext);
  if (level === "auto" && headingLevel === 0) {
    throw new Error(
      "To use auto heading levels wrap your Heading in a Region."
    );
  }
  if (typeof level === "number" && level <= 0) {
    throw new Error(
      "The level of a Heading must be a positive value greater than zero."
    );
  }
  if (level <= 6) return React.createElement(`h${level}`, props);
  return React.createElement("div", {
    ...props,
    role: "heading",
    "aria-level": level,
  });
}

However, in some cases we may want to overwrite the heading level, so let's support that too.

type HeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
  level?: number | "auto";
};

function Heading({ level = "auto", ...props }: HeadingProps) {
  const headingLevel = React.useContext(HeadingLevelContext);
  if (level === "auto" && headingLevel === 0) {
    throw new Error(
      "To use auto heading levels wrap your Heading in a Region."
    );
  }
  if (typeof level === "number" && level <= 0) {
    throw new Error(
      "The level of a Heading must be a positive value greater than zero."
    );
  }
  const actualLevel = level === "auto" ? headingLevel : level;
  if (actualLevel <= 6) return React.createElement(`h${actualLevel}`, props);
  return React.createElement("div", {
    ...props,
    role: "heading",
    "aria-level": actualLevel,
  });
}

With the changes we did, the default level is "auto" which means or Heading component will try to get the level from our Context, and in that case it must be greater than zero.

The next step is to render the Provider of our Context in the Region.

function Region(props: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <HeadingLevelContext.Provider value={1}>
      <section {...props} />
    </HeadingLevelContext.Provider>
  );
}

With this the Heading components inside our Region components will have a level of one, which is not great, we want to support nested, right? What we could do is to read the level from Context inside the Region too, this way we could get the current level and increase it by one, so if it's zero it will be one, if it's one it will be two, etc.

function Region(props: React.HTMLAttributes<HTMLDivElement>) {
  const headingLevel = React.useContext(HeadingLevelContext);
  const nextLevel = headingLevel + 1;
  return (
    <HeadingLevelContext.Provider value={nextLevel}>
      <section {...props} />
    </HeadingLevelContext.Provider>
  );
}

This will work because our default value in Context is zero.

Another improvement we could do is to ensure we have our Region and our Heading correctly linked, we can do that with the attribute aria-labelledby which receives the ID of the element used as label. In our case the Region is labelled by the Heading. So let's receive an ID in the Region and use it as aria-labelledby.

function Region({ id, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const headingLevel = React.useContext(HeadingLevelContext);
  const nextLevel = headingLevel + 1;
  return (
    <HeadingLevelContext.Provider value={nextLevel}>
      <section {...props} aria-labelledby={id} />
    </HeadingLevelContext.Provider>
  );
}

And we could use them all together with.

<Region id="timeline">
  <Heading id="timeline">Timeline</Heading>
</Region>

And it will render the following HTML

<section aria-labelledby="timeline">
  <h1 id="timeline">Timeline</h1>
</section>

Automatic ID Generation

Manually defining an ID is another error prone task, we may duplicate the ID, so let's support automatic generation, we could build our own generator or use a lib, let's use Reach UI Auto ID utility which generate ID's for you.

import { useId } from "@reach/auto-id";

const HeadingIdContext = React.createContext<string | undefined>(undefined);

function Region({ id, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const internalId = useId(id); // We use the received ID to overwrite it
  const headingLevel = React.useContext(HeadingLevelContext);
  const nextLevel = headingLevel + 1;
  return (
    <HeadingIdContext.Provider value={internalId}>
      <HeadingLevelContext.Provider value={nextLevel}>
        <section {...props} aria-labelledby={internalId} />
      </HeadingLevelContext.Provider>
    </HeadingIdContext.Provider>
  );
}

type HeadingProps = React.HTMLAttributes<HTMLHeadingElement> & {
  level?: number | "auto";
};

function Heading({ level = "auto", ...props }: HeadingProps) {
  const id = React.useContext(HeadingIdContext);
  const headingLevel = React.useContext(HeadingLevelContext);

  if (id !== undefined && props.id !== undefined && id !== props.id) {
    // We need to ensure if we pass an ID to the Heading we must pass the same ID to the parent Region
    // If we don't do this the ID and labelledby will not match
    throw new Error(
      "When wrapping a Heading in a Region, ensure you provide the same `id` to both components."
    );
  }

  if (level === "auto" && headingLevel === 0) {
    throw new Error(
      "To use auto heading levels wrap your Heading in a Region."
    );
  }

  if (typeof level === "number" && level <= 0) {
    throw new Error(
      "The level of a Heading must be a positive value greater than zero."
    );
  }

  if (actualLevel <= 6) return React.createElement(`h${actualLevel}`, props);

  if (actualLevel <= 6) {
    return React.createElement(`h${actualLevel}`, {
      ...props,
      id: id ?? props.id,
    });
  }

  return React.createElement("div", {
    ...props,
    id: id ?? props.id,
    role: "heading",
    "aria-level": actualLevel,
  });
}

With this, we can now pass an ID to both Region and Heading to use a custom one, we could even pass it only to the Region and this one will propagate it to the Heading inside.

Check a running demo in CodeSandbox.