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
Get Heading Text from the Frontmatter
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 href
attribute.
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 getStaticProps()
method.
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.
Using extract-md-headings
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, getStaticProps
, getStaticPaths
, and getServerSideProps
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.
Adding Click and Scroll-based States
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 style
attribute.
Handling the scroll state would require the use of React's useEffect
hook because it contains all the lifeCycle methods — componentDidMount()
, componentDidMount()
, and componentWillUnmount()
.
Here, I decided to track the scroll state by listening to the native scroll event with the DOM EventTarget
interface.
The function 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 headingElements
.
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.
When 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 getElementById
method.
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 window.innerHeight
and window.innerWidth
or documentElement.clientHeight
and documentElement.clientWidth
Why the stress? IntersectionObserver fixes this
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 IntersectionObserver
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.
The getBoundingClientRect
API cannot handle so many elements.
Wrapping Up
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