DEV Community

Nicky Meuleman
Nicky Meuleman

Posted on • Originally published at nickymeuleman.netlify.app

Adding a Table of Contents that updates on scroll

Adding a Table of Contents to an article can be useful to see at a glance which topics the article covers.

Scott Spence recently wrote an in depth guide on how to add a table of contents to a Gatsby blog that uses mdx.

I took the ideas from that post, expanded on them and implemented a table of contents for my blog.
If all went well, you will see a section called "TABLE OF CONTENTS" floating next to the text of this article, updating the styles to indicate which heading you're currently on.

Starting point

The following guide starts off from a functioning Gatsby blog that uses mdx (by using gatsby-plugin-mdx).

The same thing can be done with some adjustments if you are using regular markdown through gatsby-transformer-remark.

Getting the data

As a wise man once said: "Get the data, do the thing!".

The goal is to create an object that lists every heading on the page, along with the corresponding CSS-id for that heading.

Add CSS-ids to all headings

By default, headings (<h1>s to <h6>s) do not have a css id tied to them.
We want to add one to each heading. It doesn't matter how we accomplish this, only that each heading has an id.
That id will later be used in an anchor-tag to link to that heading on the page.

I did this by adding the remark-slug plugin.
<h2>Puppies are cute</h2> will turn into <h2 id="puppies-are-cute">Puppies are cute</h2>, opening the door to link to that point in the page with <a href="#puppies-are-cute">Puppies are cute</a>.

const remarkSlug = require(`remark-slug`);
// ...
  {
    resolve: `gatsby-plugin-mdx`,
    options: {
        remarkPlugins: [remarkSlug]
    }
  }
// ...

Querying for the tableOfContents object.

gatsby-plugin-mdx allows you to query for a field called tableOfContents.

import { graphql } from "gatsby";
// ...
export const blogPostTemplateQuery = graphql`
query PostBySlug($slug: String!) {
  mdx(fields: { slug: { eq: $slug } }) {
    // ...
    body
    tableOfContents
    // ...
  }
}
`;

The tableOfContents object lists the text of all headings in the document (eg. Puppies are awesome), along with the link to their corresponding CSS-id (eg. #puppies-are-awesome).
Lower level headings are nested under their higher level parents (an h3 will be nested under an h2).

Example

To visualize this better, an example!
For an .mdx file with the following headings:

## First h2

### First h3 under first h2

#### First h4 under first h3

### Second h3 under first h2

## Second h2

### First h3 under second h2

The resulting tableOfContents object would look like:

"items": [
  {
    "url": "#first-h2",
    "title": "First h2",
    "items": [
      {
        "url": "#first-h3-under-first-h2",
        "title": "First h3 under first h2",
        "items": [
          {
            "url": "#first-h4-under-first-h3",
            "title": "First h4 under first h3"
          }
        ]
      },
      {
        "url": "#second-h3-under-first-h2",
        "title": "Second h3 under first h2"
      }
    ]
  },
  {
    "url": "#second-h2",
    "title": "Second h2",
    "items": [
      {
        "url": "#first-h3-under-second-h2",
        "title": "First h3 under second h2"
      }
    ]
  }
]

Using the data

Once you query the tableOfContents for a blogpost, pass it down to its own component.
Since the tableOfContents object can be empty if there are no headings, conditionally render the <TableOfContents /> component.

{
  mdx?.tableOfContents?.items && (
    <TableOfContents items={mdx.tableOfContents.items} />
  );
}

One level of headings

The items prop is an array filled with objects.
Scroll up for a reminder of how those objects look.

For now, let's iterate over that top-level array and render the first level of headings.

function TableOfContents(props) {
  return (
    <details>
      <summary>Table of Contents</summary>
      <ol>
        {props.items.map((item) => (
          <li key={item.url}>
            <a href={item.url}>{item.title}</a>
          </li>
        ))}
      </ol>
    </details>
  );
}

Nested heading levels

How do we get the nested levels of headings to also show up?

Drumroll 🥁🥁🥁 ... RECURSION!

Recursion is one of those scary words for a concept that seems very complicated at first and suddenly clicks.
This video by Computerphile explains it beautifully

Each item we previously rendered can have an items array within it.
This can continue up to 6 times (h1 to h6).

To account for that, we'll repeat the logic we wrote to display a single level of headings.

First, a little refactor

function renderItems(items) {
  return (
    <ol>
      {items.map((item) => (
        <li key={item.url}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ol>
  );
}

function TableOfContents(props) {
  return (
    <details>
      <summary>Table of Contents</summary>
      {renderItems(props.items)}
    </details>
  );
}

In the renderItems function, check if the current item in the loop has an items property on it.

If it does, repeat the same logic for those items.

function renderItems(items) {
  return (
    <ol>
      {items.map((item) => (
        <li key={item.url}>
          <a href={item.url}>{item.title}</a>
          {item.items && renderItems(item.items)}
        </li>
      ))}
    </ol>
  );
}

The nested list goes inside a list item, according to the MDN docs

Styling the active heading

To style the link to the active heading differently from all the other headings in the table of contents, we first have to find out which one is currently active.

You can mark a heading as "active" when it's visible to the user, when it's in the viewport.
The Intersection Observer API is an ideal tool for this.

An IntersectionObserver works by first setting up the logic for it, and then telling it to start watching an element (eg. an anchor tag).

After telling an IntersectionObserver to keep track of an element, it will fire a callback function every time it is triggered (eg. the anchor tag enters the viewport).

Get all the heading-ids

To tell the InterSectionObserver which element to observe, it expects a reference to a DOM-element.
Getting references to all heading elements in the table of contents can be done by calling document.getElementById() with each heading's CSS-id.

Luckily, all the information needed for this is already there, inside the tableOfContents object.

The following helper function will take in an items array from the tableOfContents object and return a flat array that contains all the CSS-ids.

function getIds(items) {
  return items.reduce((acc, item) => {
    if (item.url) {
      // url has a # as first character, remove it to get the raw CSS-id
      acc.push(item.url.slice(1));
    }
    if (item.items) {
      acc.push(...getIds(item.items));
    }
    return acc;
  }, []);
}

Recursion was used again, to repeat the logic for nested items.

For our example above, the resulting array from getIds(props.items) in the <TableOfContents items={tableOfContents.items}/> component would be:

[
  "first-h2",
  "first-h3-under-first-h2",
  "first-h4-under-first-h3",
  "second-h3-under-first-h2",
  "second-h2",
  "first-h3-under-second-h2",
];

Get the active heading's id

We can use the data we just gathered in order to get the data we're really after: which heading is active right now?.

We'll keep track of which heading is active inside a React custom hook called useActiveId.
This hook returns a piece of state that holds the id of the heading that's currently active.

import { useEffect, useState } from "react";

function useActiveId(itemIds) {
  const [activeId, setActiveId] = useState(``);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
          }
        });
      },
      { rootMargin: `0% 0% -80% 0%` }
    );

    itemIds.forEach((id) => {
      observer.observe(document.getElementById(id));
    });

    return () => {
      itemIds.forEach((id) => {
        observer.unobserve(document.getElementById(id));
      });
    };
  }, [itemIds]);

  return activeId;
}

Using the gathered data

Back in the function for the <TableOfContents /> component, the active id can be used to change the styling of the active heading.

Pass the id of the active heading to the renderItems function.

function TableOfContents(props) {
  const idList = getIds(props.items);
  const activeId = useActiveId(idList);
  return (
    <details open>
      <summary>Table of Contents</summary>
      {renderItems(props.items, activeId)}
    </details>
  );
}

Inside the renderItems function, pass the activeId down to the recursive call of the function.

Check if the id for the current element is the same as the one passed in as activeId and adjust the styling accordingly.

function renderItems(items, activeId) {
  return (
    <ol>
      {items.map((item) => (
        <li key={item.url}>
          <a
            href={item.url}
            style={{
              color: activeId === item.url.slice(1) ? "white" : "tomato",
            }}
          >
            {item.title}
          </a>
          {item.items && renderItems(item.items, activeId)}
        </li>
      ))}
    </ol>
  );
}

Remember, the url of an item starts with a #, while the active id passed into
that function does not.

All the code

import React, { useEffect, useState } from "react";

function getIds(items) {
  return items.reduce((acc, item) => {
    if (item.url) {
      // url has a # as first character, remove it to get the raw CSS-id
      acc.push(item.url.slice(1));
    }
    if (item.items) {
      acc.push(...getIds(item.items));
    }
    return acc;
  }, []);
}

function useActiveId(itemIds) {
  const [activeId, setActiveId] = useState(`test`);
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
          }
        });
      },
      { rootMargin: `0% 0% -80% 0%` }
    );
    itemIds.forEach((id) => {
      observer.observe(document.getElementById(id));
    });
    return () => {
      itemIds.forEach((id) => {
        observer.unobserve(document.getElementById(id));
      });
    };
  }, [itemIds]);
  return activeId;
}

function renderItems(items, activeId) {
  return (
    <ol>
      {items.map((item) => {
        return (
          <li key={item.url}>
            <a
              href={item.url}
              style={{
                color: activeId === item.url.slice(1) ? "tomato" : "green",
              }}
            >
              {item.title}
            </a>
            {item.items && renderItems(item.items, activeId)}
          </li>
        );
      })}
    </ol>
  );
}

function TableOfContents(props) {
  const idList = getIds(props.items);
  const activeId = useActiveId(idList);
  return (
    <details open>
      <summary>Table of Contents</summary>
      {renderItems(props.items, activeId)}
    </details>
  );
}

export default TableOfContents;

Top comments (0)