Friday, October 1, 2021

How to build an interactive Table of Contents

In my pursuit of completely overengineering this blog, I got very hung up on the Table of Contents. I wanted an aesthetically pleasing Table of Contents that automatically highlights the section you are reading.

I think I achieved this goal pretty well with the current solution. So let's learn how to do it.

Getting the headings (from MDX)

The first major hurdle is fetching the headings from the data source you are using. In the worst-case document.querySelectorAll() is a viable option.

However, most developer blogs are written with some type of Markdown or MDX. So they are pretty much guaranteed to use the unified based parsers, such as remark and rehype.

This gives us automatic access to their respective plugin ecosystems, especially rehype-slug. Using rehype-slug adds id attributes to all the headings.

For example:

# Test Heading

will be transformed into

<h1 id="test-heading">Test Heading</h1>

With the id attributes added, it is now possible to jump to the corresponding headings with a link.

The only thing left now is to extract the headings to a usable format.

The power of remark plugins

We have discovered the power of remark plugins by now so how about builidng one ourselves to solve this issue.

In essence plugins are just transformers for the syntax trees the unified tools use internally.

One of our headings looks like this in the unified tree.

{
  type: 'heading',
  depth: 2,
  children: [
    {
      type: 'text',
      value: 'Getting the headings (from MDX)',
      position: [Object]
    }
  ],
  position: {
    start: { line: 14, column: 1, offset: 507 },
    end: { line: 14, column: 35, offset: 541 }
  }
}

A plugin has acess to all nodes and can arbitrarily transform them.

function remarkExtractTOC(){
    return tree => {...}
}

To effectively traverse the tree we will use the library unist-util-visit which exposes a visit function

function remarkExtractTOC(){
    return tree => visit(tree,(node)=>{
        ...
    });
}

This allows us to iterate over all the nodes, but note that tree-traversal gets expensive very fast. If build time becomes a concern you might want to traverse a tree only once and apply multiple transformations.

Since we only care abount headings we can use an optional second attribute to the visit function that creates a Test. In our case the Test is very simple as "heading" suffices. If you want to build a more complicated filter, look at the unist-util-is documentation. There are some very interesting possiblities.

When visiting a heading we want to grab the corresponding text. Similarily to most js frameworks, text is simply a child of type text.

visit(tree, "heading", (node) => {
  const text = node.children[0].value;
});

For most cases this will work fine, but you might want to use a more sophisticated solution in production1.

Since we don't actually want to change the tree itself (we only want to extract information from it), we need some way to pass the text to the rest of the program.

To achieve this we will (ab)use the possiblity for remark plugins to have options. We accept a (preferably empty) array as parameter and add our heading information to it.

function remarkExtractTOC(options) {
  return (tree) =>
    visit(tree, "heading", (node) => {
      const title = node.children[0].value;
      options.headings.push({
        title: title,
      });
    });
}

Since the way to add plugins with options is different for most libraries i will not cover this. It should be quite easy to look up.

Other than the title we also want to add the url for each heading. Unluckily this information is not recorded in the tree object and as such has to be generated by us.

For this we will use github-slugger, which is based on githubs algrithm, but seems to match the behavior of remark-slug very well.

function remarkExtractTOC(options) {
  return (tree) =>
    visit(tree, "heading", (node) => {
      const title = node.children[0].value;
      options.headings.push({
        title: title,
        url: "#" + slug(title),
      });
    });
}

Now we are all done and can actually build the Table of Contents.

Note: If you want hierarchy in your TOC you can add a depth attribute to the extracted headings. I do not think this adds value so I will not be covering this.

Building the Table of Contents

The basic structure of the TOC is very simple its simply a List with each listitem corresponding to one heading.

function TableOfContents({ headings }) {
  return (
    <ul>
      {headings.map((heading) => (
        <li>
          <a href={heading.url}>{heading.title}</a>
        </li>
      ))}
    </ul>
  );
}

If we want interactivity it gets a bit more complicated. As you probobly noticed by now, the currently active heading is always highlighted on this page.

The currently highlighted heading should always be:

  • The the first headline currently visible
  • The next heading above the viewport

One way to achieve this is by checking the position of all headings on every scroll. But this is very inefficient and requires tricks such as debouncing.

The lifechanging magic of IntersectionObservers

The more elegant way is to use IntersectionObservers. IntersectionObservers are made to detect whether a Element is on screen and are way more efficient.

Purple invisible
Scroll down ↓
Hello

Here the IntersectionObserver is attached to the div with the Purple background. The basic API for IntersectionObservers is by using a callback.

const observer = new IntersectionObserver(([entry]) => {
  console.log("Event!");
};

This callback will be called whenever the observed Element leaves or enters the screen.

We can now attach the Observer to a HTMLElement.

observer.observe(document.querySelector("h1"));

As opposed to real-life detectives, IntersectionObserver can observe multiple elements at the same time. Since there are multiple elements, the callback will have a list of all entries as parameter.

To check if an Element has entered or left the screen, we can use entry.isIntersecting which is true whenever the Element is present on the screen.

At the end of the lifecycle we want to unmount all observers using observer.disconnect().

Since we don't want to reconnect the observer on every Rerender, the whole Observer part of your code should be inside a useEffect block.

const element = document.querySelector("#introduction");
useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    // Update our state when observer callback fires
  });

  observer.observe(element);

  return () => {
    observer.disconnect();
  };
}, []);
// Empty array ensures that effect is only run on
// mount and unmount

This neatly takes care of all unmounting problems. Now we have all building blocks in place to build our awesome TOC.

Adding the magic

At first lets focus on observing all elements, before building the actual functionality. Since the headings are not part of Reacts VirtualDOM, we will use their id attributes to identify them.

function TableOfContents({ headings }) {
  useEffect(() => {
    const observer = new IntersectionObserver((e) => {
      // Update our state when observer callback fires
    });

    headings
      .map((heading) => heading.url)
      .map((id)=>document.querySelector(id))
      .forEach((heading) => observer.observe(heading));

    return () => {
      observer.disconnect();
    };
  }, [headings]);

  return (
    ...
  );
}

Since our Effect depends on the list of headings, this variable has to be added to useEffect's dependency array. Now our observer will reattach whenever the headings change (this really shouldn't happen).

First of we need a bit of state to keep track of which heading is currently active.

function TableOfContents({ headings }) {
const [activeId, setActiveId] = useState("");
...
}

Now lets tackle the Callback. As long as there is a heading visible we want to:

  1. Find all visible headings
  2. Get the topmost visible heading.

Since we have access to all entries in the callback, let's filter only the ones, that are intersecting.

const callback = (entries) => {
  const visibleHeadings = entries.filter((e) => e.isIntersecting);
};

The important thing is now that we traversed the mdx syntax tree in order. We then added the headings to the observer in order. As such the list of entries has to be in order. The first element of the list is the topmost heading.

To get the HTMLElement we will use the target attribute, which is basically like the current attribute of Ref.

const callback = (entries) => {
  const visibleHeadings = entries.filter((e) => e.isIntersecting);

  //visibleHeadings can easily be empty.
  if (visibleHeadings.length > 0) {
    setActiveId(`#${visibleHeadings[0].target.id}`);
  }
};

Now in theory this should work perfectly. But for some reason, if there are multiple elements intersecting at the same time, some exit events are missed. This leads to headings being skipped randomly. (Someone please explain)

To combat this we can search for the bottommost heading above the viewport, whenever there are no headings visible (the last exit is always triggered as far as I can tell). Luckily the IntersectionObserverEntry has boundingClientRect as an attribute.

In particular the bottom attribute of boundingClientRect describes the vertical positioning relative to the viewport. If the element is visible, bottom is greater than 0. To find the first heading above the viewport we simple iterate backwords until we find an entry with bottom < 0.

const callback = (entries) => {
  const visibleHeadings = entries.filter((e) => e.isIntersecting);

  if (visibleHeadings.length > 0) {
    setActiveId(`#${visibleHeadings[0].target.id}`);
  } else {
    const element = headingEntries
      .reverse()
      .find((e) => e.boundingClientRect.bottom < 0);
    if (element) {
      setActiveId(`#${element.target.id}`);
    }
  }
};

Now the activeId will always be correct, and the particular TOCEntry can be highlighted by matching its id.

function TableOfContents({ headings }) {
  const [activeId, setActiveId] = useState("");

  useEffect(() => {
    const callback = (entries) => {
      const visibleHeadings = entries.filter((e) => e.isIntersecting);
      if (visibleHeadings.length > 0) {
        setActiveId(`#${visibleHeadings[0].target.id}`);
      } else {
        const element = headingEntries
          .reverse()
          .find((e) => e.boundingClientRect.bottom < 0);
        if (element) {
          setActiveId(`#${element.target.id}`);
        }
      }
    };

    headings
      .map((heading) => heading.url)
      .map((id) => document.querySelector(id))
      .forEach((heading) => observer.observe(heading));

    return () => {
      observer.disconnect();
    };
  }, [headings]);

  return (
    <ul>
      {headings.map((heading) => (
        <li>
          <a
            className={heading.url === activeId ? "active" : ""}
            href={heading.url}
          >
            {heading.title}
          </a>
        </li>
      ))}
    </ul>
  );
}

Et voilà! A fully functional reusable table of contents.

Future

Since this is a scenario many developers face, I turned this into a fully reusable npm package.

The next post will cover the process of developing and publishing a npm package.


  1. The best solution I know of is hast-util-to-string, which supports all kinds of text formatting such as bolding, but might open up XSS vurnerabilities.↩