How to Add an Active Highlight to a Table of Contents In a Fixed Sidebar
This post describes how I got the active sidebar working on this site. It should be helpful on other platforms as well, but this is a vanilla javascript solution. There are definitely other ways of doing this, but this tutorial would be most helpful to you if you’re building a static site and want a way to use links in a fixed sidebar to help navigate through longer content.
Required Setup Work
To get started here, we’re going to want a couple of things. First, a fixed sidebar with a
tags serving as anchor links to headers with matching id
attributes on the page. Here’s what my sidebar code in hugo looks like:
<aside class="toc text-xs sm:fixed col-span-4 sm:left-2 sm:break-words sm:w-1/4 transition duration-200">
<h4>Table of Contents</h4>
{{- partial "github.html" . -}}
<div class="tocLinks">
{{ .TableOfContents }}
</div>
</aside>
The actual rendered markup for this portion looks like this:
<aside class="toc text-xs sm:fixed col-span-4 sm:left-2 sm:break-words sm:w-1/4 transition duration-200">
<h4>Table of Contents</h4>
<div class="tocLinks">
<nav id="TableOfContents">
<ul>
<li><a href="#required-setup-work">Required Setup Work</a></li>
</ul>
</nav>
</div>
</aside>
The {{ .TableOfContents }}
method in hugo generates a table of contents based on the header tags in your document, generating anchor links to each of them based on the content of the headers to where they occur in the document. If you want to customize which headers get included in the generated table of contents, you can do so like this:
[markup]
[markup.tableOfContents]
endLevel = 3
ordered = false
startLevel = 2
This configuration would add links for all h2 and h3 headers. You can read more about how to configure markup in the Hugo docs.
This approach relies on there being a list of links that have href
attributes pointing to elements on the page that have matching ids. So, in our case, an example would be:
<a href="#required-setup-work">Required Setup Work</a>
<h2 id="required-setup-work">Required Setup Work</h2>
Also, we need some way of targeting just the links inside of the table of contents, so we don’t apply any javascript to the wrong links. In this case, we can take advantage of the fact that all the links we care about are inside of a div
with a class of tocLinks
.
Required JavaScript
The main purpose of our javascript here will be to keep track of where we are in the document by attaching a scroll event listener. In that listener we’ll want to check if we’ve navigated to a new section of the page based on where the headers in our document are.
One thing to keep in mind with scroll events is to be careful to avoid doing expensive operations within the event handler. This is really important because many scroll events fire in quick succession and we don’t want to bog down the rendering of the page with a bunch of unnecessary JS. I found this article on animations in HTML5 to be really helfpul in constructing the approach.
We’re going to be using requestAnimationFrame
along with some conditional logic so we won’t actually do anything with the DOM unless the window has scrolled in such a way that a new header has passed by.
We’ll break it down a bit below, but here’s the class in full:
class Scroller {
static init() {
if(document.querySelector('.tocLinks')) {
this.tocLinks = document.querySelectorAll('.tocLinks a');
this.tocLinks.forEach(link => link.classList.add('transition', 'duration-200'))
this.headers = Array.from(this.tocLinks).map(link => {
return document.querySelector(`#${link.href.split('#')[1]}`);
})
this.ticking = false;
window.addEventListener('scroll', (e) => {
this.onScroll()
})
}
}
static onScroll() {
if(!this.ticking) {
requestAnimationFrame(this.update.bind(this));
this.ticking = true;
}
}
static update() {
this.activeHeader ||= this.headers[0];
let activeIndex = this.headers.findIndex((header) => {
return header.getBoundingClientRect().top > 180;
});
if(activeIndex == -1) {
activeIndex = this.headers.length - 1;
} else if(activeIndex > 0) {
activeIndex--;
}
let active = this.headers[activeIndex];
if(active !== this.activeHeader) {
this.activeHeader = active;
this.tocLinks.forEach(link => link.classList.remove('text-active'));
this.tocLinks[activeIndex].classList.add('text-active');
}
this.ticking = false;
}
}
To get this working, you’ll need to make a call to Scroller.init()
on DOMContentLoaded
:
document.addEventListener('DOMContentLoaded', function(e) {
Scroller.init();
})
Required CSS
Finally, for this one, I’m using tailwind css. I wanted to try out a few different colors for the links in the sidebar, so I added a class called text-active
that would be added to the link that’s in focus.
.text-active {
color: #faca50;
}
Breaking down the JavaScript
Okay, so let’s take a look at the Scroller
class and talk through what’s happening.
init()
static init() {
if(document.querySelector('.tocLinks')) {
this.tocLinks = document.querySelectorAll('.tocLinks a');
this.tocLinks.forEach(link => link.classList.add('transition-colors', 'duration-200'))
this.headers = Array.from(this.tocLinks).map(link => {
return document.querySelector(`#${link.href.split('#')[1]}`);
})
this.ticking = false;
window.addEventListener('scroll', (e) => {
this.onScroll()
})
}
}
Inside of init()
we’re checking if the document has an element with a class of tocLinks
. If not, we’re not going to do anything, because we’re not on a page that has a table of contents and we don’t need any of this other code.
If this .tocLinks
selector returns an element, then we store all of the links in a property this.tocLinks
and add transition classes so that the color changes are smooth. If you’re not using hugo and are generating the anchor tags another way, you can add these classes into the template directly, but because hugo is generating these anchor tags for me, I’m adding those classes here via js.
After that, we create a property called this.headers
that transforms an array of table of contents links to an array of the html tags that they’re linking to. Since we have the same number of links as we do tags to link to, we’ll be able to use the index later on to access the appropriate DOM node again without having to do another DOM query.
In this last bit, we’re defining a property this.ticking
and setting it to false initially. We’re doing this so we can prevent triggering requestAnimationFrame
when one is already in progress. Finally, we’re attaching a scroll event listener to the window
and passing an arrow function (so we maintain access to the same this
context) and invoking onScroll()
.
onScroll()
Let’s take a closer look at the onScroll
function.
static onScroll() {
if(!this.ticking) {
requestAnimationFrame(this.update.bind(this));
this.ticking = true;
}
}
So we check if we’re not already ticking, and, if not, we trigger requestAnimationFrame then set this.ticking
to true
so the next scroll
event won’t immediately trigger requestAnimationFrame
again. This works because the callback we pass to requestAnimationFrame
is asynchronous. This means it won’t be invoked until the browser is ready to do its next repaint. So, this.ticking
will be set to true
before the this.update
method is invoked. Note, we’re passing a bound version of the this.update
method, because we want our callback to have access to the same this
context. This could also be accomplished by passing an arrow function instead: () => this.update()
.
Okay, so this onScroll()
method doesn’t make total sense until we look at it within the context of the bigger picture which needs to include the update()
method.
update()
This is where the bread and butter of the functionality actually lives.
static update() {
this.activeHeader ||= this.headers[0];
let activeIndex = this.headers.findIndex((header) => {
return header.getBoundingClientRect().top > 180;
});
if(activeIndex == -1) {
activeIndex = this.headers.length - 1;
} else if(activeIndex > 0) {
activeIndex--;
}
let active = this.headers[activeIndex];
if(active !== this.activeHeader) {
this.activeHeader = active;
this.tocLinks.forEach(link => link.classList.remove('text-active'));
this.tocLinks[activeIndex].classList.add('text-active');
}
this.ticking = false;
}
Before we hop through it in order line by line, let’s jump down to the bottom and check out this.ticking = false;
This line is super important because it’s what opens up our onScroll
method to request another animation frame only after the callback has finished executing. This is how we prevent too many attempts to repaint. We only request another animation frame after we’ve completed the last one.
Okay, so back to the approach. The goal here is to keep track of where we are in the document (with respect to the headers in the table of contents) as we scroll.
So, from the top. We’re storing a reference to this.activeHeader
. We’re using ||=
here so that we don’t overwrite this if it already exists from a previous call to update()
but it will be set to the first header if it’s the first time we’re calling update()
. Then, our goal is to find the index of the active header. To do this, we’re using the getBoundingClientRect()
method so we can access the top
property. This property will tell us how far the top of that element (in this case a particular header) is from the top of the viewable window. This value will change as we scroll. It will begin as positive and then decrease as we scroll down through the document. So, as we scroll, the top header will have the most negative value for the top
property whereas the bottom header will have the largest value.
Once we find the index of the first header that is more than 180px from the top of the viewport, we have some logic to check and see what the index is. The reason for this is we need to behave differently depending on the repsonse. If we get -1
back, that means that there is no header visible on the page more than 180px
from the top of the screen. Essentially, this means for us that we’re at the bottom of the page and the active header should be the last header on the page. To affect this change, we assign activeIndex
to this.headers.length - 1
in the case where it’s actually -1
.
If we get back 0
for the activeIndex
this indicates that the first header on the page is visible and more than 180px
from the top of the window, so it should be the active
header. If we get any number above 0
then we’re subtracting 1
from the activeIndex
. Effectively, what this means is that the active
header won’t switch to the next header until that header is 180px
or less from the top of the viewable window. So, as you scroll down, you’ll notice that the active header changes after it crosses that line 180px
from the top of the window.
Now, let’s expand upon the end part of the update()
method:
let active = this.headers[activeIndex];
if(active !== this.activeHeader) {
this.activeHeader = active;
this.tocLinks.forEach(link => link.classList.remove('text-active'));
this.tocLinks[activeIndex].classList.add('text-active');
}
this.ticking = false;
We’re setting the active
variable to the header at the activeIndex
we established above. The conditional logic allows us to avoid doing any DOM manipulations if they’re not necessary. The way it works is we check if the active
header is different from the this.activeHeader
that we’ve got stored. If it is, then we set this.activeHeader
to active
, remove the text-active
class from all of the tocLinks
and add the text-active
class to the link in this.tocLinks
that matches the activeIndex
that belongs to the active
header.
Finally, as we discussed earlier, this.ticking
is set to false
so that requestAnimationFrame
can be triggered with update
again on a scroll after this call has completed.