DLM Tutorials

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.

Keep working in the woodshed until your skills catch up to your taste.
If you'd like to get in touch, reach out on LinkedIn.