When you visit documentation sites, you'll notice that many of them have a common component: the
<TableOfContent /> component.
The idea behind it is to give the reader a "heads-up" about the information they're trying to consume.
This feature, in turn, helps the reader go directly to the section that includes a solution to whatever bug or issue they're facing, without reading the whole article. It contributes to a good User Experience because you end up saving your audience the hassle of extra scrolling and searching.
I have a personal blog that I dedicate a lot of my time to. And for a long time, I thought about adding this feature. This will help anyone visiting my site to enjoy their time and find what they need.
This article is a summary of my process, so you don't have to go through the issues I went through. If you're trying to add a Table of Contents feature to your blog, you can walk with me.
I shared a video of what the component looked like after completing it. You can take a look at it here
To build a table of contents feature, I knew what I needed to do. Since the articles on my blog are written in markdown, I am just using a superset of markdown – MDX – which allows me to use React components in markdown files.
The first thing on my list was to get a way to render the heading text in a component. This way, when people clicked on the headings, the browser would scroll to that point in the article.
With HTML, you can achieve this by using the anchor tag and passing the value to an
To have linked text pointing to a section, the ideal way of doing this would look like what's in the snippet below:
In the snippet above, the anchor tags are tied to the sections with respect to their
id attribute in the DOM. When you click on any text, it takes you to the respective section.
With this mental model, I thought of populating the frontmatter of each article with the headings in all the articles I've written. I knew it was going to be stressful, but I went with it anyway.
For context, this is what a frontmatter in a markdown file looks like. A frontmatter contains the metadata of all articles on my blog. Details like the title, date it was published, the tags or category that the article falls into, the description, a canonical URL, and any other thing you may feel like adding to improve your article's SEO.
This pattern is common when you're building blogs with Next.js and MDX (markdown in general). It has a YAML-like syntax too.
The snippet above is what the frontmatter of this article looks like, but with the
headings entry. I'm using that to explain my initial approach. If I go ahead and map through the frontmatter, I'll be able to retrieve the content from the headings array.
It felt nice because I'll be able to use the items in the
headings array in the TableOfContent component. It felt surreal, and I was elated for a minute. The component looked like this:
The component above receives a headings prop alone, which in turn receives a value from the frontmatter through the Next.js
If everything in the snippets above seemed intricate. You can take a look at this article where I wrote about the process of setting up a Next.js blog.
With that out of the way, the component rendered the list of items from the frontmatter, and it looked fine.
But, the moment I clicked on an item, hoping to be scrolled to that section, it did not work as expected, because I ran into an error, which you'll see in the next section.
I realized that when I clicked on an item in the component, the browser encoded the URL of the current slug with an encoding parameter for spaces –
%20% – which in turn led to the issue.
Although, I realized that it could be the way I was referencing the heading elements in the
frontmatter. But, I couldn't care less, because I found another alternative and it worked great.
That alternative I mentioned, after I made sure that it worked perfectly, I went on and published it as a package to the Node Package Manager Registery.
The package extends a function.
extractHeadings() that accepts a string, as a path, to where the markdown file is and extracts any text that matches how heading texts are written in markdown files. You can take a look at the source here if you want to see how it works under the hood.
With this tool in my arsenal, the
getStaticProps method is modified to use the function. Why? you might ask me. This is because the package depends solely on Node's
fs module, which equates to a server-side scripting approach.
With Next.js we can perform server-side operations in the pages directory with any of the data-fetching methods,
Now that the
[slug].js page is aware of the
fileContent through the
heading prop from the
TOC component. I need to modify it so it would accommodate the properties that the function returns.
For now, the component just renders the list of items in the array that is returned from the function, with no interactivity, no way to track which element is active, and many more that I haven't been able to add for now.
If there's anything I so much love about React, it is this – ability to track state. I've seen how this works on other doc platforms, when you click on an item, it becomes active, when you scroll into the section where there's a heading tag, it becomes active.
A lot of people have different approaches to monitoring these states. I chose to go with the simplest one — changing the color — because, as usual, "I no like stress". The default text color in my component's UI is sorta "grey-ish", so when it is active, it becomes white.
I'll start with the snippets of the modification I made to the component with the
useState hook, some DOM APIs, and the
getBoundingClientRect web API. It is long! I know 😩. But, please stay with me, I'll try to break it down, simply.
It is a common approach to have a default value — a boolean, string, or Number — when we use the
useState hook. In the snippet below, the component uses the
headings prop to check if the length — of the array isn't empty — is greater than zero, and sets the default state of the component to that of the first element.
If the array is empty, no element will have the active state style. For now, if you place an
onClick attribute in the list element — like I did — and pass the
slug as an argument, It'll toggle the style you'd have written in the
Handling the scroll state would require the use of React's
useEffect hook because it contains all the lifeCycle methods —
Here, I decided to track the scroll state by listening to the native scroll event with the DOM
handleScroll below maps the result we're getting from the
extractHeadings() function by destructuring the
slug property from the object. It proceeds to return all the elements containing a proper
id attribute with
getElementById and assigns the value to
Still in this function, the
visibleElements is filtered from the array of
headingElements, and the
isElementInViewport function is used to check which heading element is currently in the viewport — this is possible with
getBoundingClientRect, I'll get to that soon.
The function closes with a condition to set an active element if the length of the visible headings is greater than zero.
Now, I can go ahead to wrap this function in the Effect, initiate the cleanup of the scroll event, and pass the
headings prop inside the dependency array, so that the Effect is only triggered when the
headings prop changes.
isElementInViewport is the cherry on top of this feature.
The function accepts an element,
el as an argument, and it checks if its bounding rectangle — this sorta proves the box principle on the web to be correct, again. — is inside the viewport of the browser.
This is possible because of the
getBoundingClientRect web API. The method returns an object containing the coordinates of the top, left, bottom and right edges of the element relative to the viewport.
getBoundingClientRect is called, it returns an object containing the coordinates of the top, left, bottom, and right edges of a particular heading element relative to the viewport.
In the context of this feature, the element that is relative to the viewport is the heading element which is being retrieved using the
The function returns true if the top and left coordinates are greater than or equal to zero, and the bottom and right coordinates are less than or equal to the height and width of the viewport, respectively.
For the function to return
true, we'd have to get the value of the viewport's height and width, that's why it is convenient to compare these values with
I know that going the
intersectionObserver route would've saved me a lot of stress. But, I chose this approach nonetheless, because I wanted to understand the inner workings of how this feature is built by other people.
I think there's an intersection observer package that you can use to monitor scroll events too, in React applications. So you may not even need to go this route. But I want to share some of the reasons I decided to use this API, instead of the
In terms of Accuracy,
getBoundingClientRect returns a more accurate position of the element relative to the viewport, while
IntersectionObserver uses an approximation based on the element's bounding box.
This means that
getBoundingClientRect can be more precise for certain use cases, such as when you need to trigger an action as soon as the element enters the viewport — just like we're changing the active state of the list item in the component.
In terms of Browser compatibility. the
IntersectionObserver is a relatively new API, and its support by other browsers may not be available. But,
getBoundingClientRect on the other hand is widely supported by modern browsers.
One advantage that the
IntersectionObserver has over
getBoundingClientRect is in terms of Performance and this is because the API uses an optimized algorithm that minimizes the amount of work needed to detect the changes in the intersection state when you are tracking so many elements.
getBoundingClientRect API cannot handle so many elements.
I know that a lot of people would still prefer to use the
intersectionObserver. But, I decided to go with this one because, It opened my eyes to how the
intersectionObserver itself works under the hood, and most importantly, it suited my use case.
This is what the logic of the TOC component looks like — without the markup. Copy it, and use it, if you want.
If you read up to this point. Please share this article. Thanks as you do so. You can also read up on the getBoundingClientRect() web API, if you want to get an in-depth understanding