DEV Community

Cover image for Add a table of contents (TOC) to your blog
Rob OLeary
Rob OLeary

Posted on • Updated on • Originally published at roboleary.net

Add a table of contents (TOC) to your blog

A table of contents (TOC) can serve as a summary for your page and enable readers to quickly navigate its contents.

You may be thinking, "boring, this has been loads of time before and is easy". Actually, I have seen nothing clear and concise written about this topic, especially something that is beginner-friendly, and that considers accessibility properly. Often, the examples focus on doing something fancy such as having the table of contents (TOC) display your current position in the document.

So, let's cover the basics really well and give you a spring-board to get fancy.

Solutions for generating a TOC

Many of the popular static site generators (SSGs) and content management systems have this functionality built-in, available as a plugin, or through the markdown processor that they use. What is typically generated for you is an ordered lists (ol) of links that point to the headings in the page. ID generation is required to make your headings (or sections) referencible. Usually an unique slug, a human-readable ID, is generated and added to each heading in their id attribute. We will cover the specific HTML in the next section.

You probably have some options to configure the TOC such as:

  • You can specify the location of the TOC with a class name or through some specific markup,
  • Include only certain heading levels,
  • Choose to make it an ordered list (ol) or unordered list (ul),
  • Exclude specific headings.

What most solutions do for you is to add the HTML for the TOC to your webpage. It is up to you to style it, and add functionality.

Here are what the solutions offered by some of the popular SSGs:

  1. Jekyll has the jekyll-toc plugin. I can vouch for this one, as I use it on an active website. It has excellent configuration options. It is one of the few I have seen where you can exclude individual headings within a page, so you can slim down the size of the table of contents.
  2. The Kramdown markdown parser-converter that is used by Jekyll, has the ability to generate a TOC. If all is want is a TOC, it does the job.
  3. Eleventy has the eleventy-plugin-toc plugin.
  4. Hugo has built-in support for adding a TOC.

If you use a JavaScript application framework such as Gatsby (React) or Next (React) or Nuxt (Vue), you will find that people have probably made a plugin, or posted a solution for adding the functionality. For example, Gatsby has the gatsby-remark-table-of-contents plugin.

WordPress has a lot of options. For example, there is the Easy Table of Contents plugin.

Basic HTML and styles

Let's look at the HTML generated typically and see if we should add anything to it.

HTML

This is typical of the HTML that is generated for you:

<ol id="toc-list">
  <li>
    <a href="#how-do-i-use-shortcuts">How do I use shortcuts?</a>
  </li>
  <li>
    <a href="#how-to-add-and-modify-keybindings">How to add and modify keybindings</a>
  </li>
  <!-- More list items here, which may contain nested lists -->
</ol>
Enter fullscreen mode Exit fullscreen mode

Since we have a group of links that we use to navigate to different sections of the page, it makes sense to wrap them in a nav element.

As MDN says:

The <nav> HTML element represents a section of a page whose purpose is to provide navigation links, either within the current document or to other documents. Common examples of navigation sections are menus, tables of contents, and indexes.

We wrap our ol with a nav, and include a h2 heading, as below:

<nav class="toc" aria-labelledby="secondary-navigation">
  <h2 id="secondary-navigation">Table of Contents</h2>
  <ol id="toc-list">
      <!-- same as before -->
   </ol>
</nav>
Enter fullscreen mode Exit fullscreen mode

Since a nav is a landmark element, we should give it a label. We should label our nav so that it can be identified by assistive technologies such as screen readers.

We can label our nav using the aria-labelledby attribute that points to the heading. The value of the id is provided as the value to aria-lablledby to link them. The h2 should concisely describe the purpose of the section to be used as the label.

If you do not have a heading (you should), or it does not concisely describe the nav, you can use the aria-label attribute on the nav instead.

Styles

Our blog posts will have a single column layout. This is the most common layout.

Elsewhere around the web, you may have seen a two column layout with the TOC in a sidebar. This is more common for documentation-centric websites like MDN web docs. They tend to hide the TOC for smaller screen sizes.

We won't get into different layouts. We can touch on some options of displaying the TOC differently in the Fancy features section.

Our TOC is a group of nested lists. So, you can style it as a list any way you want! You can use list-specific properties for the following:

  • You can change the style of the markers (the bullets or numbers) with the list-style-type property,
  • change the appearance of the markers through the ::marker psuedo-element,
  • have images for markers through list-style-image property,
  • and even make you own numbering scheme through CSS counters.

Based on the default browser styles, our table of contents will look something like this:

unstyled table of contents

It is a bit ugly!

The numbering of the nested lists makes it a bit confusing to read. The numbers restart at 1 at every level. It is probably better to remove the numbers and use the indentation to show the levels, or to use an ordinal numbering scheme.

Let's start by giving it a clear appearance as its own section.

Basic appearance

First, we will add some styles to make it stand out a bit. We can give the nav a darker background colour, a subtle border, and we will round the corners with border-radius .

nav {
  background-color: #fffff9;
  border: 1px solid lightgrey;
  border-radius: 5px;

  display: flex;
  flex-direction: column;
  align-items: center;
  row-gap: 1rem;  
}
Enter fullscreen mode Exit fullscreen mode

Next, we will make it a flexbox container (display: flex), mainly so that we can center the items. We change the flex-direction to "column" to have the items in a single column. Then, we center everything with align-items: center; and we can also control the space between the items with row-gap.

toc basic styles1

Let's style the links. I think removing the underline and using a less saturated colour would be an improvement. You can use the hsl() color function and reduce the second number to get a less saturated color.

.toc a {
  text-decoration: none;
  color: hsl(193, 46%, 39%);
}
Enter fullscreen mode Exit fullscreen mode

And it looks like this:

toc basic styles 2

The spacing looks a bit off. By default, the h2 and ol have a margin-top and margin-bottom. Let's remove these by setting margin to zero, and we will just be relying on row-gap to set the space between them. However, now the heading and list right on the edges of the nav, so let's add padding to the table of contents itself to let them breathe a bit!

.toc h2,
.toc ol {
  margin: 0;
}

.toc nav{
   /* same styles as before */

   padding: 1rem;
}
Enter fullscreen mode Exit fullscreen mode

toc spacing adjusted

It looks more organised, right?

Good, but we're not done!

The next thing we will probably want to change is the numbered markers. Let's explore 2 options for this. First, we will look at removing the markers and use indentation to show the hierachy. Then, we will look at using an ordinal numbering scheme for the markers.

Remove markers and indent

To remove the markers is simple. You just use list-style-type:none; on the ol element.

Now, let's consider the padding. By default, an ol has a padding-right, the value is 40 pixels in Firefox. We can turn on the flex outline in the Firefox's devtool to see the spaces. I check the box as circled in green in the screenshot below to show the outline.

toc flex outline

What I would like is for the top ol to have no padding, and any nested ol to have padding-right of 2 rem. We can add a padding-right of 2 rem to all ol with the selector .toc ol. And subsequently we can target the top ol to give it a padding of zero by using the > child combinator, the selector would be .toc > ol.

I think it is a good habit to use logical properties because the browser support is very good now. Logical properties allow declaring styles that work with all writing systems, so I will use paddding-inline-start instead of padding-right! This means the CSS below should give the desired outcome for a right-to-left language such as Arabic, as well as a left-to-right language such as English

.toc ol {
  list-style-type: none;
  padding-inline-start: 2rem;
}

.toc > ol {
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode

Here is the result:

I think it is a good improvement from what we had originally! You may still want to tweak it, or take it in another direction!

Ordinal numbering

Maybe the most common style that I have seen for a table of contents is an ordinal numbered list. What do I mean by ordinal numbering?

Each item is numbered according to the order of its heading level, beginning with the first h2 heading. This is usually in the format of <h2-sequence-number>.<h3-sequence-number>.<h4-sequence-number>. . It is probably easier to understand by exploring a specific example!

Below is an example of table of contents from Wikipedia, an article about the River Lee:

wikipedia-example-toc

You can see how the numbers corresponds to the headings. The link to the first h2 is given the number 1. The link to the second h2 is given the number 2, with it's first h3 given the number 2.1, and so on.

But remember, the default styling for nested ordered lists restarts the numbering for each list (as below)!

ol-default-styling

We can use CSS counters to number the items the way we would like.

To use a counter, it must first be initialized with the counter-reset property. Below we initialize a counter and name it toc-counter . By default, counters have an initial value of 0, and count up from the initial value.

.toc ol {
    list-style-type: none;

    /* initialise counter */
    counter-reset: toc-counter;
}
Enter fullscreen mode Exit fullscreen mode

We remove the existing numbers for our list by setting list-style-type: none;. We are going to put our custom numbers into a ::before pseudo-element instead.

Once initialized, a counter's value can be incremented using counter-increment. We increment the counter in a ::before pseudo-element for the list items. The value of the counter can be displayed using the counters() function in the content property of the pseudo-element, as below.

.toc li::before {
  /* display the counter formatted as our ordinal style */
  content: counters(toc-counter, ".") " "; 

  counter-increment: toc-counter;

  color: hsl(14, 51%, 54%); /* reddish brown color */
}
Enter fullscreen mode Exit fullscreen mode

The counters() function has two forms: counters(<counter-name>, <separator>) and counters(<counter-name>, <separator>, <counter-style>). We will use the latter form. Our separator will be a dot (period). For the counter-style, we provide a space. This adds a space to the end of the counter text that will separate the number from the text content of the list item.

And this is the result:

Smooth scrolling to sections

When clicking internal links, it can be a more pleasant experience to smoothly scroll down to the section rather instantly jumping to it. To achieve this, you can add this to your stylesheet:

html {
    scroll-behavior: smooth;
}
Enter fullscreen mode Exit fullscreen mode

Collapsible functionality

It would be nice to be able to show the TOC in a minimized or maximized state. If it is a bigger TOC, we may prefer to have the content hidden initially, and let it up to the user to show it.

This functionality is an example of the disclosure UI pattern. There are two main approaches to implementing this, and neither is perfect. It is one of those areas that has some shortcomings and should be easier by now!

Option 1) Use details and summary

We can use the combination of the details and summary elements to provide this functionality. These two together are usually referred to as a “disclosure widget”.

HTML

<div class="toc">
    <details open>
        <summary>
            <h2>Table of Contents</h2>
        </summary>
        <nav aria-label="table of contents">
            <ol id="toc-list">
                <!--items here-->
            </ol>
        </nav>
    </details>
</div>
Enter fullscreen mode Exit fullscreen mode

We need to wrap everything in a div, so that we can style it later. You cannot layout details as a flexbox or grid container as you would expect. The summary element is treated like an implicit element, so it is not laid out as a flexbox/grid item.

By default, the disclosure widget is closed. We add the open attribute to show the contents.

I added an aria-label to the nav to give it an accessible label, however I am not sure if it is possible to make this example fully accessible. The issue is that summary has an implicit ARIA role of button, this means that it eats the semantics of elements inside it. So our h2 contained in the summary is treated like a span really! In cases where the TOC is in a closed state, it will not be announced by screen readers.

If you know of a clear way to make this fully accessible, let me know!

CSS

The default appearance of the summary is:

  • When the content is hidden, it is styled with a right-pointing triangle to hint that activating the button will display additional content.
  • When the content is visible, the triangle points down.

Left unstyled, disclosure widgets present us with two issues:

  1. The summary cursor icon: Though the summary section is an interactive element, the element’s default cursor is a text selection icon rather than the pointing hand that you may expect. The pointing hand hints to a user that clicking is possible. This is desirable!
  2. Block elements in summary are positioned on the line below the marker, like in the screenshot below.

default-gap

To rectify these issues, we can add the following styles to "reset" the behavior to what we would typically expect:

summary { 
  cursor: pointer;
  user-select: none;
}

summary > * {
  display: inline;
}
Enter fullscreen mode Exit fullscreen mode

To center everything, we wrap our nav in a div, and make it a flexbox container. We still need to use text-align:center; on the h2 to center it as it is a rogue element!

To create space between the h2 and the nav, we add a margin-block-start to the nav. We won't use row-gap because of the summary issue.

.toc {
  display: flex;
  flex-direction: column;
  align-items: center;
}

nav {
  margin-block-start: 1rem;
}

h2{
    text-align: center;
}
Enter fullscreen mode Exit fullscreen mode

Pros

This solution has no JavaScript.

It supports keyboard interaction when it has focus. Enter or Space activates the disclosure control and toggles the visibility of the disclosure content.

Cons

Accessibility is a mixed bag. The summary element is like a button and buttons do not respect the semantics of child elements. Some screenreaders will treat everything inside the summary element like a span. So our h2 will not be announced properly to a user with a screen reader. Dave Rupert discusses this further in his article, Why details is Not an Accordion. You can see the latest accessibility testing results to see the current state on this.

Styling is a bit awkward. Not being able to make details a flexbox or grid container is limiting.

Styling of the marker is a bit limited too. Surprisingly, it is done through list-style properties, you can provide an image to list-style-image. When you specify your own marker, you cannot add any transitions when it changes from an open to closed state.

Overall, the implementation of details and summary is a bit ragged really.

Option 2) Use a button and JavaScript for interactivity

HTML

<nav class="toc" aria-labelledby="toc-heading">
    <header>
        <h2 id="toc-heading">Table of Contents</h2>
        <button aria-controls="toc-list" aria-expanded="true">Hide</button>
    </header>
    <ol id="toc-list">
        <!-- items here -->
    </ol>
</nav>
Enter fullscreen mode Exit fullscreen mode

We add the following aria attributes to make it fully accessible:

  • aria-labelledby="IDREF": Added to the nav to give it an accessible name. We link it to the h2.
  • aria-expanded: Indicates that the container controlled by the disclosure button is hidden when the value is false and visible when the value is true . Set the value to true as we show the contents by default.
  • aria-controls="IDREF": The disclosure button controls visibility of the container identified by the IDREF value. Alternatively, you could use aria-owns.

CSS

There are many ways to hide elements in CSS.

We want the following:

  1. We want the space to collapse when the content is hidden (no empty space left behind),
  2. The content to be hidden from the accessibility API when it is a hidden state. I am not totally sure if this is necessary if we use aria-expanded.

If you also want to animate the transition as well, it is trickier to find a solution that checks all 3 boxes.

Without animation, looking at our options:

  • visibility:collapse has issues, according to MDN: "Support for visibility: collapse is missing or partially incorrect in some modern browsers."
  • The properties transform, color, opacity, visibility: hidden and clip-path all leave empty space when we are in the "hidden" state. Overlaying a pseudo-element has this issue also.
  • Reducing dimensions such as height means the content is always accessible. We could toggle the value of aria-hidden ourselves. It triggers rerendering the page layout, which we prefer to avoid if possible.
  • display: none is the most commonly used. Resetting to the previous style is usually fine, but keep in mind that it has many options such as block and inline. It triggers rerendering the page layout, which we prefer to avoid if possible.
  • Absolute positioning to move it offscreen, but you would probably need to combine it with opacity for it to work effectively.

There is no clear winner! We will go with display: none to keep things simple (sigh).

.hide{
    display:none;
}
Enter fullscreen mode Exit fullscreen mode

We can toggle this class in JavaScript.

JavaScript

const button = document.querySelector(".toc button");
const content = document.querySelector("#toc-list");

button.addEventListener("click", toggleTableOfContents);

function toggleTableOfContents() {
  content.classList.toggle("hide");

  if (button.innerText === "Hide") {
    button.innerText = "Show";
    button.setAttribute("aria-expanded", false);
  } else {
    button.innerText = "Hide";
    button.setAttribute("aria-expanded", true);
  }
}
Enter fullscreen mode Exit fullscreen mode

The button supports keyboard interaction when it has focus by default, so we do not need to do add any keybindings. We change the state of aria-expanded, so that its state is available to the assistive technologies.

Pros

This solution is completely accessible (correct me if I missed something).

We have complete control over the style of the disclosure button.

Cons

JavaScript is required.

Option 3) Use a link (a element) and JavaScript for interactivity

Don't do it! Go with option 2 instead of this option!

Why?

A good rule of thumb is: buttons are for actions ("do something for me"), whereas links are for navigation ("take me to a different location)". Chris Ferdinandi spoke about this recently in his HTML semantics daily tip. Not everyone is taught this, so I am glad that this message is being put out there more often.

You may be thinking but doesn't Wikipedia use a link for this behaviour?

Yes, they do. And do not copy them! 😁

Which option should I chose?

Option 1 is the simplest and being JavaScript-free is always great. However, if you care about maximizing the accessibility of your website (you should), then option 2 is the better option for now. I am not accessibility expert, but I don't see a way to get it right at the moment with option 1.

Fancy features

Here I will skim through some examples of fancy things you can do. I will only touch on the code, but I will point to a tutorial if there is a good one out there. I may tackle one of the fancy examples in another post, you can make a request if you wish!

Sticky single column layout

It can be helpful to the reader to have the TOC always in view.

The simplest way to do this is to make add position: sticky; to the TOC. You probably want it to stick to the top of the window, so you can it use along with the top property.

.toc {
    position: sticky;
    top: 0;
}
Enter fullscreen mode Exit fullscreen mode

One thing to keep in mind is that if your TOC is inside a grid or flex container, you may want to add self-align:start to it to ensure it is not out of reach to become sticky! You need to be able to scroll beyond an element for it to switch to a fixed position, so sometimes if you align or justify an element, it can hamper this from happening.

The obvious drawback of this approach is that you don't want the TOC to hog the screen when the user wants to read the article. Here we have it in a minimized state initially and leave it up to the user to view if they want to.

I think that this is alright, but it could be taken further to make it a better user experience. It can be a bit confusing when you want to scroll the TOC to see the other items, and actually the page scrolls in the background instead. It depends on where the focus is! An overlay that freezes the scrolling of the page would make it easier to use.

An alternative is to have the TOC shrink to a button, once it becomes sticky. Pawel Cislo does something like this. The TOC is maximised inline in the article until you scroll down a certain amount, and then it appears minimized as a button in the right of the article. You can see in the video below, or visit this blog post to see in action yourself.

I think it would benefit from a transition effect of the TOC to a button to make it clearer to the user what happened. There is a big distance between the original position and new position.

Hashnode has a nice variation of this. Hashnode is a blogging platform if you are unfamiliar with it. By default, the TOC is maximised. When you scroll beyond the TOC, the TOC is minimized and becomes sticky tab pinned to the top of the window. You can see it in action in the video below, or visit this blog post to try out yourself.

It quite an elegant solution for a single column layout. Although, I think the transition is a bit abrupt.

Ben Holmes takes a different tack on his blog. A button pops up as you scroll through the post. Underneath the button, it shows the name of the section you are in. Pressing the button will open the TOC with the current section highlighted. You can see it in action in the video below.

It works quite well. In particular, he has made smart use of space on smaller screens by putting the button and section heading into a sticky nav bar.

ben holmes blog on mobile with sticky nav table of contents

The only criticism I have is that you can't scroll through the TOC really. The actual page scrolls instead. An overlay might work here to enable targeted scrolling.

Sticky two column layout

It is quite common to have a sticky TOC in a sidebar, like the MDN web docs, as in the screenshot below.

mdn-sidebar-sticky-toc

MDN hides the TOC for smaller screen sizes when it switches to a single column layout. You can use a media query to do this.

I would like to see to see a switch to something similar to our single column layout for smaller screen sizes if it can be executed well.

Show your progress in the page (scrollspy)

Another fancy thing people like to add is a progress scrollspy. As you go through the article, the section you are in will be highlighted in the TOC. This is usually implemented in a 2-column layout with the TOC in the sidebar.

You can see this in the wild in the following places:

  • Ben Holmes: As shown previously, Ben has this on his blog,
  • MDN web docs: The TOC is only visible on screens greather than 800px,
  • Josh Comeau's blog: The TOC is only visible on screens greater than 1100px.

Bramus Van Damme has a nice tutorial on how to make this yourself. He covers semantic markup, how to implement most of the functionality with HTML and CSS, and then how to do the last bit with JavaScript.

Below is the complete example from that tutorial:

There are many variations on this too. It is a popular bit of blink people like to implement.

I don't know if it is all that useful though!

Wrapping up

We covered a lot of ground! I hope I was able to break down the different considerations of adding a TOC to your blog.

From the outset, it looks simple. However, to make it look nice, and make it accessible to as many people as possible, it requires more effort. And of course, there are plenty of ways you can dress it up! 🎩🧐

Top comments (1)

Collapse
 
robole profile image
Rob OLeary • Edited

hamilton tablecloth rip