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:
- 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.
- 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.
- Eleventy has the eleventy-plugin-toc plugin.
- 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>
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>
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/unstyled-toc.png)
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;
}
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/toc-basic-styles1.png)
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%);
}
And it looks like this:
![toc basic styles 2](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/toc-basic-styles2.png)
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;
}
![toc spacing adjusted](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/toc-spacing-adjusted.png)
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/toc-flex-outline.jpg)
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;
}
Here is the result:
{% jsfiddle jsfiddle.net/robjoeol/wsjkun45 result,html,css %}
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/wikipedia-example-toc.png)
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/ol-default-styling.png)
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;
}
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 */
}
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:
{% jsfiddle jsfiddle.net/robjoeol/q2n1c0zt result,html,css %}
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;
}
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”.
{% jsfiddle jsfiddle.net/robjoeol/hty19jva result,html,css %}
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>
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:
- The
summary
cursor icon: Though thesummary
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! Block elements in
summary
are positioned on the line below the marker, like in the screenshot below.![default-gap](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/default-gap.png)
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;
}
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;
}
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
{% jsfiddle jsfiddle.net/robjoeol/6da74g2y result,html,css,js %}
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>
We add the following aria attributes to make it fully accessible:
aria-labelledby="IDREF"
: Added to thenav
to give it an accessible name. We link it to theh2
.aria-expanded
: Indicates that the container controlled by the disclosure button is hidden when the value isfalse
and visible when the value istrue
. Set the value totrue
as we show the contents by default.aria-controls="IDREF"
: The disclosure button controls visibility of the container identified by theIDREF
value. Alternatively, you could use aria-owns.
CSS
There are many ways to hide elements in CSS.
We want the following:
- We want the space to collapse when the content is hidden (no empty space left behind),
- 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
andclip-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 ofaria-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 asblock
andinline
. 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;
}
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);
}
}
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;
}
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.
{% jsfiddle jsfiddle.net/robjoeol/xm3d86y7 result,html,css,js %}
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/ben-holmes-toc-mobile.png)
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](roboleary.net/assets/img/blog/2022-06-27-ad.. your-blog-posts/mdn-sidebar-sticky-toc.png)
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:
{% codepen codepen.io/bramus/pen/ExaEqMJ %}
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! 🎩🧐