Adding a Table of Contents that updates on scroll
Tags: tutorial, gatsby, mdx
Published: 2020-05-11 12:35:58

---
title: Adding a Table of Contents that updates on scroll
published: true
tags: tutorial,gatsby,mdx
canonical_url: https://nickymeuleman.netlify.app/blog/table-of-contents
---

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

[Scott Spence](https://twitter.com/spences10) recently wrote [an in depth guide](https://thelocalhost.blog/2020/02/13/smooth-scroll-toc-gatsby/) on how to add a table of contents to a [Gatsby](https://www.gatsbyjs.org/) blog that uses [mdx](https://mdxjs.com/).

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](https://nickymeuleman.netlify.app/blog/table-of-contents), updating the styles to indicate which heading you're currently on.

## Starting point

The following guide starts off from a functioning [Gatsby](https://www.gatsbyjs.org/) blog that uses [mdx](https://mdxjs.com/) (by using [`gatsby-plugin-mdx`](https://www.gatsbyjs.org/packages/gatsby-plugin-mdx/)).

> The same thing can be done with some adjustments if you are using regular markdown through [gatsby-transformer-remark](https://www.gatsbyjs.org/packages/gatsby-transformer-remark).

## Getting the data

As [a wise man](https://twitter.com/Dayhaysoos) 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`](https://github.com/remarkjs/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>`.

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

### Querying for the `tableOfContents` object.

[`gatsby-plugin-mdx`](https://www.gatsbyjs.org/packages/gatsby-plugin-mdx/) allows you to query for a field called `tableOfContents`.

```js
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:

```json
"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](https://reactjs.org/docs/conditional-rendering.html) the `<TableOfContents />` component.

```jsx
{
  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](#example).

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

```jsx
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](https://www.youtube.com/channel/UC9-y-6csu5WGm29I7JiwpnA) explains it beautifully

{% youtube 8lhxIOAfDss %}

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

```jsx
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`.

```jsx
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](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ol#Nesting_lists)

## 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](https://developer.mozilla.org/en-US/docs/Web/API/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()`](https://developer.mozilla.org/nl/docs/Web/API/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.

```js
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](#example) above, the resulting array from `getIds(props.items)` in the `<TableOfContents items={tableOfContents.items}/>` component would be:

```js
[
  "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](https://reactjs.org/docs/hooks-custom.html) called `useActiveId`.
This hook returns a piece of state that holds the id of the heading that's currently active.

```js
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.

```jsx
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.

```js
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

```jsx
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;
```