How to build a table of contents in React
A table of contents lets your readers see a high-level summary of your page. In this tutorial, we’ll be building a table of contents (or “TOC”) component with React. This component will dynamically render a list of page headings and highlight which heading you are currently viewing.
Here’s our final product:
If you are viewing this post on a large enough screen, you will be able to see it in action right here as well.
Looking for a full code example? Check out the tutorial’s Codepen.
Get started with a new TOC file
To begin, let’s create a new TableOfContents
file.
💡 The
nav
HTML element indicates parts of the page that provide navigation links, like a table of contents. Thearia-label
identifies this nav element to screen reader users.
Plop this component into where you want it to render. If you have a main App.js
file, you could render it there alongside your main content:
Add some CSS to make it sticky
There’s a couple of features that we want to add to our table of contents:
- Keeping it sticky as the user scrolls down the page
- Showing a scrollbar if it’s longer than the height of the page
Now, you’ll have a sticky component that will follow you up and down the page as you scroll.
Make sure all your headings have IDs
For your headings to be link-able, they will need to have a unique id
value:
Finding all the headings on the page
For this TOC component, I’ll be rendering all the <h2>
and <h3>
elements on the page.
We’ll create a useHeadingsData
hook, which will be responsible for getting our headings. We’ll do this using querySelectorAll
:
You’ll notice there’s a getNestedHeadings
function. Since the query selector returns a list of h2 and h3 elements, we have to determine the nesting ourselves.
If our headings looked something like this:
We would want to nest the "Third header"
underneath its parent:
To achieve this, we’re going to store all h2 objects in a list. Each h2 will have a items
array, where any children h3s will go:
In getNestedHeadings
, we’ll loop through the heading elements and add all h2s to the list. Any h3s will live inside the last known h2.
Render your headings as a list of links
Now that we have our nestedHeadings
value, we can use it to render our table of contents!
Let’s keep things simple and start by rendering all the h2 elements. We’ll create a new Headings
component to take care of that.
Add your nested headings
We’ll then want to render our nested h3s. We’ll do this by creating a new sub-list underneath each h2:
Make your browser smoothly scroll to headings
Right now if we click on a header link, it will immediately jump to the header.
With scrollIntoView, we can instead ensure that it smoothly scrolls into view.
Add an offset when you jump to a heading
You might also notice that the heading is very close to the top of the page. We can create a bit of space between the heading and the top of the page when it is jumped to:
However scroll-margin-top
seems to be buggy on Safari. Actually with Safari, I find that if I cancel out the scroll-margin-top
, things seem to work fine:
Find the currently “active” heading
The final step is to highlight the currently visible heading on the page in the table of contents. This acts as a progress bar of sorts, letting the user know where they are on the page. We’ll determine this with the Intersection Observer API. This API lets you know when elements become visible on the page.
Instantiate your Intersection Observer
Let’s create an Intersection Observer. It takes in a callback function as its first argument, which we’ll keep empty for now.
You can also pass in a rootMargin
value. This determines the zone for when an element is “visible”. For instance on my site I have -110px
on top and -40%
on the bottom:
The -110px
is the height of my sticky nav at the top, so I don’t want any content hidden under there to count as “visible”.
The -40%
means that if a header is in the bottom 40% of the page, this doesn’t count as being “visible”. If a heading is visible near the bottom of the page, you’re probably not actually reading it yet.
Observe your headings to listen for when they scroll in and out of view
After creating the observer, you need to call observe()
on each of the elements we want to observe. In our case, this is all the h2
and h3
elements on the page.
You’ll also want to call disconnect()
when you unmount.
Store heading elements from callback function
Next, we’ll need to write the code for our callback function. The observer will call this function each time elements scroll in or out of view.
When you first render the page, it calls the callback with a list of all the elements on the page. As elements scroll in and out of view, it will call the callback with these elements.
Since we want to keep track of the visibility of all the heading elements, we’ll store these values in a useRef
hook. You can learn more in my post about storing values with useRef, but essentially by storing it in useRef, we don’t cause any unnecessary re-renders (compared to storing it in React state).
Calculate the index of the active heading
Each heading element in our headings
list has a isIntersecting
(or “is visible”) value. It’s possible to have more than one visible heading on the page, so we’ll need to create a list of all visible headings.
We’ll also create a getIndexFromId
function. This will let us determine the position of a heading given its ID.
Finally, we’ll choose the visible heading that is closer to the top of the page. We pass in a function called setActiveId
that we’ll call once we have found the value.
If there are no visible headings, we’ll do nothing, and keep the last visible heading as our “active” heading.
Properly handling when you scroll back up
A big thanks to Ky Wildermuth in the comments section, who pointed out that if you scrolled past a really long section of text, and then scrolled back upwards, the header would not correctly update until you reached it. Also a big thank you to Heisman2, who has a suggested fix for this which I have copied here:
To summarise, for this to work, we need to:
- Alsoass in a
activeId
to theuseIntersectionObserver
- Add some additional logic to make a section active if we are scrolling on it (even if we can’t see any headers)
- Make sure that we also pass in
activeId
as one of the dependencies in theuseEffect
Highlight the currently active heading
We’ll create an activeId
state variable to store the currently “active” heading. Then we can pass that information into our Headings
component:
And then add an active
class to the currently active heading:
Finally, you’ll need some CSS to go along with your active
class name:
Making your anchor links update
If you hover over one of the headings on this page, you’ll see that it has anchor links. e.g. the heading for this section has the link /react-table-of-contents/#making-your-anchor-links-properly-update
. This means that you can link to this specific heading on the page.
I recently realised that if you click the links in the table of contents, this does not update the URL. This is because we are doing a e.preventDefault();
so that we can make use of the smooth scrolling animation.
If you want to keep the animation, I found a Stackoverflow solution which seems to work.
To get around this, you could manually assign the URL after the scroll animation finishes with the following:
Unfortunately though the scrollend
event isn’t yet supported on Safari.
Conclusion
And you’re done! 🎉 You’ll now have a dynamically generated table of contents that will live alongside the contents of your posts.
Looking for the full code? Check out this tutorial’s Codepen.
PS: Creating a table of contents with Gatsby
If you’re using Gatsby, the methods we’re using above won’t work with server-side rendering (SSR). This means for a Gatsby blog your table of contents will be empty when the page first loads, before they render in.
Gatsby lets you grab the table of contents via GraphQL for both Markdown and MDX. This way you can render the table of contents on the initial server-side render.
With Markdown, you can add tableOfContents
to your page’s GraphQL query:
This will return you an HTML table of contents which you can directly render on the page:
Similarly with MDX you can add tableOfContents
to your GraphQL query:
This returns a list of top-level headings. Any child headings will live inside of the items
array. This data follows a similar structure to nestedHeadings
so it should be straightforward to re-use in your code.
PPS: Creating a table of contents with Astro
If you’re using Astro, it also can generate a headings object for you. For example if you are rendering blog via a contents collection, you will probably have a [slug]
page to generate all these posts. From post.render()
you will be able to get a headings
object:
I pass this headings object down into my TOC component. The headings are typed like the following:
So once I get my headings object, I do a little transformation to get it working with my existing React TOC component:
I do this transformation because I originally wrote this TOC component for my Gatsby blog, before porting it over to Astro. So you may be able to find a more clean solution or one that uses this shape directly instead of transforming it!