Building a HTML Table of Contents with Automatic Highlighting



Photo by Patrick Tomasso on Unsplash

Often when browsing documentation and articles, we want to jump to a specific section of interest. This guide will walk through creating a table of contents which automatically highlights the section the reader is currently viewing. We’ll write this purely in HTML, CSS and JavaScript.


Demo

Unlike most popular novels, we’ll begin at the end. Below is a short demo of what we’ll be building, which you can also find on code sandbox. As we scroll up and down the page, the side menu automatically shows which section we’re reading. We can also pick a section at random, and quickly scroll to it.

Page Setup

To start our project, we need two main things. First, a list of links which jump to various parts of the webpage. Next, we need the actual content of our page. You can fill this webpage with beautifully written text, or in our case, pages of placeholder text.

<!DOCTYPE html>
<html lang="en">
  <head>
    <link rel="stylesheet" type="text/css" href="index.css" />
    <script defer src="index.js"></script>
    <title>HTML Table of contents</title>
  </head>
  <body>
    <ul>
      <li><a href="#introduction">Introduction</a></li>
      <li><a href="#heading1">Heading 1</a></li>
      <li><a href="#heading2">Heading 2</a></li>
      <li><a href="#heading3">Heading 3</a></li>
    </ul>
    <article>
      <h1>Article</h1>
      <h2 id="introduction">Introduction</h2>
      <p>
        ...
      </p>
      <h2 id="heading1">Heading 1</h2>
      <p>
        ...
      </p>
      <h2 id="heading2">Heading 2</h2>
      <p>
        ...
      </p>
      <h2 id="heading3">Heading 3</h2>
      <p>
        ...
      </p>
    </article>
  </body>
</html>

We can now add some basic CSS so that our webpage doesn’t hurt our eyes. It’s also quite important for making sure that the sidebar is actually at the side.

Add the following CSS into index.css.

html {
  font-family: Arial, Helvetica, sans-serif;
}

section {
  display: flex;
}

article {
  margin-left: 10rem;
}

article * {
  max-width: 30rem;
}

ul {
  position: fixed;
  list-style-type: none;
  width: 10rem;
}

a {
  text-decoration: none;
  color: black;
}

a:hover {
  color: blue;
}

.active {
  color: blue;
}

This will do for now, although feel free to change the styling to fit your specific needs.

Adding Functionality

Photo by Wilhelm Gunkel on Unsplash

With all our setup complete, we can move onto adding functionality. Add the following JavaScript code into index.js.

The first thing we need is a function to check whether an element is in the current viewport. Now this should be pretty easy to write, especially when you can copy it directly from stack overflow. Removing all the jQuery bits, we end up with this:

function isElementInViewport(el) {
  var rect = el.getBoundingClientRect();

  return (
    rect.top >= -1 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

From here, we’re going to use the Intersection Observer API to check when our elements intersect with the viewport. We’ll write a handler which will be run every time one of our headings enters or leaves the viewport.

The general idea for the handler is that we want to iterate through our headings, and find the first one in the viewport. This will be used to describe the section currently being read. If there are no headings in the viewport, it defaults to the last viewed heading.

We check both manually using isElementInViewport, as well as by using the entries returned by the intersection observer. This ensures that we can capture the correct section, no matter which direction our user is scrolling.

const handler = (entries) => {
  // Get all the links from the side menu
  const allLinks = document.querySelectorAll("ul li a");

  // Get all the sections we want to track
  const allSections = document.querySelectorAll("h2");

  // Get all entries that have just come into the viewport
  const allEntries = new Set(
    entries
      .filter((entry) => entry.isIntersecting == true)
      .map((entry) => entry.target)
  );

  let currentSection;

  // Look through all sections
  for (let i = 0; i < allSections.length; i++) {
    // Get the current section
    currentSection = allSections[i];
    // If the section is in the viewport or it has just intersected, set it as active
    if (isElementInViewport(currentSection) || allEntries.has(currentSection)) {
      // Set all links as inactive
      allLinks.forEach((link) => link.classList.remove("active"));
      // Set current link to active
      document
        .querySelector(`a[href="#${currentSection.id}"]`)
        .classList.add("active");
      // Exit loop after setting first active link
      break;
    }
  }
};

Note that we only set the first link to active, and then break out of the loop. This ensures that only the first viewable section is highlighted.

Using this handler, we can simply create our observer and observe every heading we created earlier.

// Create a new observer with the handler
const observer = new IntersectionObserver(handler);

// Observe all headings on our webpage
document.querySelectorAll("h2").forEach((section) => {
  observer.observe(section);
});

With this done, we now have a functional, automatically highlighting table of contents.


Wrapping Up

Photo by Austin Distel on Unsplash

With our table of contents conquered, we can move on to making it bigger and better. We can add more sections, add subsections, and change the styling to make it look even better. We won’t cover this here, although it should be fairly straightforward to extend.

Hopefully this provides an understanding of how to highlight the currently viewed section. You can use this as a platform to create a dynamic table of contents perfect for your website.


Resources

Code Sandbox Demo

isElementInViewport Code

Intersection Observer API