Having worked on my site for a little while, I've started to think of ways in which I can add better functionality to various pages. One idea which surfaced to me from viewing other website was the inclusion of a dynamic table of contents element on blog post pages. What I initially thought could be solved by a plugin or simple script ended up requiring a little experimenting and design to execute correctly. Before getting into the specifics, I'll start by providing some background on what a dynamic table of contents is.
Introduction
Adding a dynamic table of contents with active link highlighting to any webpage can be incredibly beneficial. A dynamic table of contents is an interactive element of a webpage that allows users to quickly and easily navigate to any section of the page. Furthermore, active link highlighting allows users to easily identify which link they've selected and which section of the page they are currently viewing.
The primary reason for adding a dynamic table of contents with active link highlighting is to improve the user experience. By having a clearly visible, interactive table of contents, users can quickly and easily jump to any section of the page without having to scroll endlessly. In addition, active link highlighting provides visual confirmation to the user that they have selected the desired link and are now viewing the corresponding section. This eliminates the guesswork and confusion that can occur when navigating a page with a large amount of content.
A dynamic table of contents with active link highlighting can also help to improve the overall design aesthetic of a website. By displaying the table of contents in a visually appealing way, users can get a better sense of the overall structure of the page and quickly identify the sections they are looking for. This can help to create a more organized and intuitive experience for users.
In essence, adding a dynamic table of contents with active link highlighting to any webpage can have numerous benefits. It can improve the user experience by making it easier to navigate the page, reduce page loading times, and improve the overall design aesthetic. As such, it is an incredibly useful tool for any website.
Planning and Research
What initially spurred this effort was seeing how other sites seemed to all riff on a similar idea, and that idea was having a table of contents element for articles which was aware of what the user was looking at. MDN makes use of such a feature on their web doc pages (as seen here).
Before doing any actual coding, I did a survey of available plugins for Gatsby sites like mine. In a previous post, I briefly mentioned how this site had been initially created using the Gatsby blog starter. As such, it came with a few plugins already installed. Specifically, a series of plugins for remark such as gatsby-transformer-remark. These plugins don't offer much in the way of customization for how the remark is interpreted. One specific plugin I have is gatsby-remark-bulma which inserts styles and additional elements to make Bulma work with the rendered elements.
What I found was there isn't really a plugin available for generating dynamic tables of contents for this specific setup. There's gatsby-remark-table-of-contents, but that inserts a table of contents into the HTML rendered from the blog's markdown. Unfortunately, this is a bit too restrictive for what I'm trying to accomplish. I thought it would be too challenging to make this work with Bulma's column layout system.
So my next thought was to simply incorporate some client-side code from somewhere else on the web. Specifically, I was hoping to find a code snippet on a website like Stack Overflow or CodePen. Sadly, I wasn't able to find any snippet which worked. A lot of solutions I've seen for this problem use sections with IDs for determining which part of the document a user is looking at. For example, see this discussion and this other discussion from Stack Overflow. Generally, I think it would be easier to solve this if the document has sections with IDs. However, I wasn't able to find a plugin which would automatically add section elements for me, and I didn't want to solve the problem of injecting sections into the rendered HTML. Maybe I can do that later though.
Additionally, there was another problem of getting the actual HTML for the list of elements which make up the table of contents. The Gatsby plugin does provide a list of headings for the document, however these headings are returned as a flat array. Each element of the array represents a heading and has attributes for the ID, value, and depth of the heading. I spent some time researching on how to turn this flat array into a nested object, but that started to get too time-consuming. What I eventually found was the Gatsby plugin can return an ordered list of rendered HTML for the table of contents. Super convenient, but it would present a small (yet easily solvable) problem later.
After completing the research, I realized the simplest path would be to use the pre-rendered HTML provided by the plugin for the table of contents, and then write my own client-side JavaScript code to supply the dynamic functionality.
Design and Development
First, I had to rethink how I wanted my blog post pages to appear. Originally, I had the content displayed down the center of the page. With the addition of a table of contents element, I wanted the new element to appear on the right of the page as a sticky sidebar while the actual article content dominated the left side.
What I opted for was essentially the following:
<section className="columns">
<div className="column is-three-quarters">
{/* ... Blog post content here ... */}
</div>
<div className="column">
<div className="table-of-contents pt-6 hide-from-touch">
<div className="box">
<p className="menu-label">Table of Contents</p>
<nav className="panel">
<div id={tableOfContentsId} className="menu-list" dangerouslySetInnerHTML={{__html: post.tableOfContents}} />
</nav>
</div>
</div>
</div>
</section>
For those wondering, the post.tableOfContents
comes from the data returned by the GraphQL query used to populate the page. The tableOfContents
is provided as a Remark attribute. As mentioned previously it's rendered HTML, so it has to be added to the page via the dangerouslySetInnerHTML
attribute.
markdownRemark(id: { eq: $id }) {
id
excerpt(pruneLength: 160)
html
frontmatter {
title
date(formatString: "MMMM DD, YYYY")
description
}
tableOfContents
}
There are two custom classes I've got here. The first is table-of-contents
and the second is hide-from-touch
. You can see the SCSS in the following snippet:
.hide-from-touch {
@include until($widescreen) {
display: none !important;
}
}
// From https://stackoverflow.com/a/63293145
.table-of-contents {
position: sticky;
display: inline-block;
vertical-align: top;
max-height: 100vh;
overflow-y: auto;
width: auto;
top: 0;
bottom: 0;
}
The hide-from-touch
class is one I came up with which uses Bulma's responsiveness variables and responsiveness mixins to hide the table of contents when the user is viewing the site on a touchscreen device (anything smaller than a widescreen). On the other hand, the table-of-contents
class is copied from this Stack Overflow comment and turns the table of contents into a sticky sidebar.
The other classes are all provided by Bulma and can be customized using Bulma's SASS variables later if I want a different look.
Lastly, I added some client-side JavaScript code via the useEffect
React hook. Initially, my though process was I could use the window.scrollY
attribute to find the closest heading based the y
attribute of each element's bound client rectangle. However, I wanted to ignore the h1
and h2
elements at the top of the page since those are not part of the article content. Here's my initial approach (with some helpful comments added for illustrative purposes in this blog post):
// Gather all the links from the table of contents.
const links = document.querySelectorAll(`#${tableOfContentsId} a`)
const linkMap = new Map()
// Create a way to quickly get a link based on the text of a heading. Each link corresponds to a heading,
// so their text content matches. Hence, the text content is used as the key.
links.forEach(link => linkMap.set(link.textContent, link))
// Get the headings from the page, less the h1 and h2 at the very top.
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).filter(heading => {
return !heading.classList.contains('headline') && !heading.classList.contains('sub-headline')
})
// Here is how we'll determine where the user is at on the page.
window.addEventListener('scroll', () => {
links.forEach(link => link.classList.remove('is-active')) // Deactivate all links.
// Now a simple algorithm to determine which heading is closes to the y position of the scroll.
var closestHeading = null
headings.forEach(heading => {
if (!closestHeading || Math.abs(heading.getBoundingClientRect().y) < Math.abs(closestHeading.getBoundingClientRect().y)) {
closestHeading = heading
}
})
// Use the map to switch the link corresponding to the closes heading to active.
linkMap.get(closestHeading.textContent).classList.add('is-active')
})
Essentially, every time the page is scrolled the event will fire and highlight the link corresponding to the heading closest to the top of the viewport. As we'll see in the next section, there are some flaws to this approach which were easily remedied.
Testing and Debugging
While testing out this approach, I noticed there were some problems with the behavior of the JavaScript:
-
Scrolling to the bottom of the page wouldn't highlight links to smaller sections.
- For example, if the last section was too small then the section right before it would be highlighted.
-
Scrolling past the first section wouldn't deactivate the active link.
- If I went back to the top of the page after getting to the end of the article, then the link for the first section wouldn't deactivate.
To solve these problems, I did the following:
- Created a check to see if the bottom of the page had been reached.
- Created a check to see if the user is less than 100 pixels from the first section (an arbitrary number I thought created an intuitive effect when scrolling). Note, this second check uses the fact that the
y
attribute of the bounding box is less than zero when you scroll past the element.
Here's what the client-side JavaScript was updated to:
const isNewHeadingCloser = (heading, closestHeading) => {
return Math.abs(heading.getBoundingClientRect().y) < Math.abs(closestHeading.getBoundingClientRect().y)
}
// New method to check if we've scrolled to the bottom of the page. Based on https://stackoverflow.com/a/9439807.
const isWindowScrolledToBottom = () => {
return (window.innerHeight + window.scrollY) >= document.body.offsetHeight
}
const links = document.querySelectorAll(`#${tableOfContentsId} a`)
const linkMap = new Map()
links.forEach(link => linkMap.set(link.textContent, link))
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).filter(heading => {
return !heading.classList.contains('headline') && !heading.classList.contains('sub-headline')
})
window.addEventListener('scroll', () => {
links.forEach(link => link.classList.remove('is-active'))
// Check if we've started scrolling and the first heading has been reached before we start making links active.
if (window.scrollY > 0 && headings[0].getBoundingClientRect().y < 100) {
var closestHeading = null
headings.forEach(heading => {
if (!closestHeading || isNewHeadingCloser(heading, closestHeading) || isWindowScrolledToBottom()) {
closestHeading = heading
}
})
linkMap.get(closestHeading.textContent).classList.add('is-active')
}
})
Note how I've also abstracted out one of the conditional checks from the first version into a named function called isNewHeadingCloser(...)
. This was done to make the code clearer.
Conclusion
In conclusion, adding a dynamic table of contents with active link highlighting to a website can greatly improve the user experience by making it easier to navigate the page, and improve the overall design aesthetic. Although it took some planning, research, and experimentation, I was ultimately able to implement this feature on my website using pre-rendered HTML and custom client-side JavaScript code. It's been a rewarding experience to see this project come to life, and I'm excited to continue exploring ways to enhance the functionality and design of my website in the future.
See below for a demo of the table of contents in action:
Top comments (0)