DEV Community

Billy Le
Billy Le

Posted on • Originally published at billyle.dev on

Highlight Table of Content Items Using Intersection Observer

Giving your readers a way to navigate through the Table of Contents (ToC) is a nice feature but I was still missing a critical detail that would make the experience much more pleasant.

The missing feature was a way to highlight or give some sort of visual indicator of which part of the ToC the reader was viewing.

I've adapted the work of Reza Zahedi as I did before in my other blog post so all credit goes to him.

You'll more or less likely find the same information in his blog and this post and I'm creating this post as an entry for my own record.

A bit about AstroJS remark support

AstroJS ships with remark, a markdown processor with many community-built plugins.

You can add things like linters, MDX support, or compile your markdown to PDFs. The list goes on and on.

I am going to use the remark-sectionize plugin. This plugin will parse through my markdowns and for every article heading greater than <h1>, it will wrap a surrounding <section> element.

To see what I mean, here is an example:

Before :

<article>
  <h1>My Article Title</h1>
  <h2 id="heading-1">This is the first heading</h2>
  <p>This paragraph is about whatever heading-1 is about.</p>
  <h2 id="heading-2">This is the second heading</h2>
  <p>This paragraph is about whatever heading-2 is about.</p>
</article>

Enter fullscreen mode Exit fullscreen mode

After :

<article>
  <h1>My Article Title</h1>
  <section>
    <h2 id="heading-1">This is the first heading</h2>
    <p>This paragraph is about whatever heading-1 is about.</p>
  </section>
    <h2 id="heading-2">This is the second heading</h2>
    <p>This paragraph is about whatever heading-2 is about.</p>
  </section>
</article>

Enter fullscreen mode Exit fullscreen mode

It seems simple enough, right?

Highlight Table of Contents

For now, I simply want to change the text color in the ToC whenever a reader is viewing that section.

To do so, I have to use the Intersection Observer API which will allow me to manipulate the DOM elements as they enter or leave the viewport.

Here are the steps I'll need to complete it:

  1. Give my ToC a class name, .toc-links for selecting the DOM element.
  2. Select all <section> elements within the <article> tag.
  3. Create an Intersection Observer and write a callback function to process the event and data.
  4. Inside the callback, find the heading element of that section, map it to the ToC, and toggle on/off the class active as they enter or leave.
  5. Loop over the <section> tags from Step 1 and use the Intersection Observer we created to observe each section.

The Intersection Observer API

If you have never used or heard of Intersection Observer, it's a Web API that allows us to listen to events and trigger functions when an element is entering or leaving the viewport.

This is the perfect use case for using the Intersection Observer API because we want to manipulate DOM elements whenever the events fire.

We will create an Intersection Observer that will change the class name of the <a> tags inside our Table of Contents whenever we are viewing the corresponding section.

The implementation code

In my BlogLayout.astro file that is responsible for rendering the very HTML page you're reading, I'm going to write a <script> tag.

Here is the code:

<script>
  const articleSections = document.querySelectorAll<HTMLDivElement>("article section");

  const observer = new IntersectionObserver((entries) => {
    entries.map((entry) => {
      const heading =
        entry.target.querySelector<HTMLHeadingElement>("h2,h3,h4,h5");
      if (!heading) return;
      const id = heading.getAttribute("id");
      if (!id) return;
      const link = document.querySelector<HTMLAnchorElement>(
        `.toc-links a[href="#${id}"]`,
      );
      if (!link) return;

      const addRemove = entry.intersectionRatio > 0 ? "add" : "remove";
      link.classList[addRemove]("text-blue-500", "dark:text-blue-400");
    });
  });

  for (const section of articleSections) {
    observer.observe(section);
  }

  window.document.addEventListener("beforeunload", () => {
    observer.disconnect();
  });
</script>

Enter fullscreen mode Exit fullscreen mode

Breaking the code down

const articleSections =
  document.querySelectorAll<HTMLDivElement>("article section");

Enter fullscreen mode Exit fullscreen mode

I'm collecting all the article sections using the document.querySelectAll() function.

const observer = new IntersectionObserver((entries) => {});

Enter fullscreen mode Exit fullscreen mode

I'm creating a new IntersectionObserver() that takes a callback function whenever it is fired.

The entries parameter is coming from the intersection event triggered and is an array of IntersectionObserverEntry.

entries.forEach((entry) => {});

Enter fullscreen mode Exit fullscreen mode

Using the entries parameter from the callback, we loop over it in a .forEach().

From each entry, there is a target. That target is the HTML element that fired the event. In my case, it will be a <section> tag.

How does it know that it's a section element? Well, I'll explain later in the code.

const heading = entry.target.querySelector<HTMLHeadingElement>("h2,h3");
if (!heading) return;

Enter fullscreen mode Exit fullscreen mode

To get the heading of the section, we use entry.target.querySelector<HTMLHeadingElement>("h2,h3") and store it in a variable called heading. There is a guard clause to return if nothing is found.

const id = heading.getAttribute("id");
if (!id) return;

Enter fullscreen mode Exit fullscreen mode

Next, I find the id attribute by calling heading.getAttribute("id") and store that in another variable called id.

const link = document.querySelector<HTMLAnchorElement>(
  `.toc-links a[href="#${id}"]`,
);
if (!link) return;

Enter fullscreen mode Exit fullscreen mode

Next up, find the associated <a> tag by using string interpolation and storing that into a link variable.

const addRemove = entry.intersectionRatio > 0 ? "add" : "remove";
link.classList[addRemove]("text-blue-500", "dark:text-blue-400");

Enter fullscreen mode Exit fullscreen mode

Using the entry variable from before, we can detect when a section is entering or leaving by using the intersectionRatio. If the intersectionRatio is greater than 0, the element is entering, and when it's below 0, it is leaving.

The addRemove variable stores the key of the classList API so we can easily toggle on and off the class names.

If the section is being viewed, I changed the ToC item to a blue text and off when the section is no longer in view.

for (const section of articleSections) {
  observer.observe(section);
}

Enter fullscreen mode Exit fullscreen mode

Now that the Intersection Observer is created with a callback function, we can observe elements in our DOM to invoke the callback as they enter or leave.

In this case, I loop over all the sections from my articleSections variable and observe them.

window.document.addEventListener("beforeunload", () => {
  observer.disconnect();
});

Enter fullscreen mode Exit fullscreen mode

You may not need this but I added this part anyway. Before a user navigates away from the page, I want to disconnect the observer.

Summary

As you can probably see, the text turns blue when you go from one section to the other.

If there is a child section within a parent section, such as an h3 within an h2 section, it still keeps the parent heading highlighted.

This is great since most articles have a hierarchy.

Congratulations! You learned to add this simple feature in your blog or anywhere you need to highlight different parts of your site by using the Intersection Observer API.

You can do some more fancy stuff with the Intersection Observer like this Progress Navigation by Hakim El Hattab that I found via Kevin Drum.

If you end up implementing this, let me know! I would love to see your work.

Until next time, have a good one, and happy coding!

Top comments (0)