# Adding a Table of Contents to MDX Articles - Modern Next.js Blog Series #24

- Canonical: https://easonchang.com/posts/post-side-toc
- Date: 2022-10-09T00:00:00.000Z
- Language: en
- Description: Adding a sidebar table of contents to Contentlayer blog articles to provide readers with a clear view of the article structure, using github-slugger and IntersectionObserver

> This article is also published at [it 邦幫忙 2022 iThome Ironman Contest](https://ithelp.ithome.com.tw/articles/10306476)

After adding a page progress bar in the previous article, we continue with another dazzling and useful feature: "Article Sidebar Table of Contents," allowing readers to grasp the structure of the article at a glance!

The final effect is as follows:

![table of contents dark](https://i.imgur.com/W6Zsfzs.jpg)

![table of contents light](https://i.imgur.com/FLUBZfe.jpg)

The code changes for this article are as follows:

https://github.com/eason-dev/nextjs-tailwind-contentlayer-blog-starter/compare/day23-nprogress...day24-table-of-contents

---

## Adding a Table of Contents to the Side of Article Pages

We will add two types of interactions for the upcoming table of contents: one is highlighting the currently viewed section title in red as you scroll through the article, and the other is auto-scrolling to the corresponding section of the article when you click any title in the table.

The latter effect, scrolling on click, utilizes the anchor links added with [rehype-slug](https://github.com/rehypejs/rehype-slug) in [Article 20: Adding Anchor Links to Headings](/posts/post-heading-anchor).

### Installing github-slugger to Convert Title Text to Slugs

The table of contents needs to know the titles and their anchors within the article. Here, we need [github-slugger](https://github.com/Flet/github-slugger) to convert title text to slugs.

The internal implementation of rehype-slug also uses github-slugger for slug conversion, so there's no need to worry about inconsistency.

Let's install github-slugger:

```shell
pnpm add github-slugger
pnpm add -D @types/github-slugger
```

### Passing the Original MDX Content of Articles to Article Pages to Capture All Titles

The sidebar table of contents needs to know all the titles in the article. We will implement it to only display `h2` and `h3` titles.

We can extract all titles from the `##` and `###` in the original MDX content of the article.

The Post object passed to us by Contentlayer includes a `body.raw` attribute, where we can get the original MDX content of the article.

Modify `src/pages/posts/[slug].tsx` to pass it to the article page:

```tsx
// ...
export const getStaticProps: GetStaticProps<Props> = ({ params }) => {
  // ...
  const post: PostForPostPage = {
    title: postFull.title,
    date: postFull.date,
    description: postFull.description,
    path: postFull.path,
    socialImage: postFull.socialImage || null,
    body: {
      code: postFull.body.code,
      // Add the following line
      raw: postFull.body.raw,
    },
  };
// ...
```

### Adding the `<TableOfContents/>` Component to Display the Table of Contents on the Side of Article Pages

Add `src/components/TableOfContents.tsx`, where the logic and styles for the table of contents are defined:

```tsx
// ref: https://github.com/ekomenyong/kommy-mdx/blob/main/src/components/TOC.tsx

import clsx from "clsx";
import GithubSlugger from "github-slugger";
import { useEffect, useRef, useState } from "react";

// eslint-disable-next-line no-unused-vars
type UseIntersectionObserverType = (setActiveId: (id: string) => void) => void;

const useIntersectionObserver: UseIntersectionObserverType = (setActiveId) => {
  const headingElementsRef = useRef<{
    [key: string]: IntersectionObserverEntry;
  }>({});

  useEffect(() => {
    const callback = (headings: IntersectionObserverEntry[]) => {
      headingElementsRef.current = headings.reduce((map, headingElement) => {
        map[headingElement.target.id] = headingElement;

        return map;
      }, headingElementsRef.current);

      const visibleHeadings: IntersectionObserverEntry[] = [];

      Object.keys(headingElementsRef.current).forEach((key) => {
        const headingElement = headingElementsRef.current[key];
        if (headingElement.is

Intersecting) visibleHeadings.push(headingElement);
      });

      const getIndexFromId = (id: string) =>
        headingElements.findIndex((heading) => heading.id === id);

      if (visibleHeadings.length === 1) {
        setActiveId(visibleHeadings[0].target.id);
      } else if (visibleHeadings.length > 1) {
        const sortedVisibleHeadings = visibleHeadings.sort(
          (a, b) => getIndexFromId(b.target.id) - getIndexFromId(a.target.id)
        );

        setActiveId(sortedVisibleHeadings[0].target.id);
      }
    };

    const observer = new IntersectionObserver(callback, {
      rootMargin: "0px 0px -70% 0px",
    });

    const headingElements = Array.from(
      document.querySelectorAll("article h2,h3")
    );

    headingElements.forEach((element) => observer.observe(element));

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

type Props = {
  source: string;
};

const TableOfContents = ({ source }: Props) => {
  const headingLines = source
    .split("\n")
    .filter((line) => line.match(/^###?\s/));

  const headings = headingLines.map((raw) => {
    const text = raw.replace(/^###*\s/, "");
    const level = raw.slice(0, 3) === "###" ? 3 : 2;
    const slugger = new GithubSlugger();

    return {
      text,
      level,
      id: slugger.slug(text),
    };
  });

  const [activeId, setActiveId] = useState<string>();

  useIntersectionObserver(setActiveId);

  return (
    <div className="mt-10">
      <p className="mb-5 text-lg font-semibold text-gray-900 transition-colors dark:text-gray-100">
        Table of Contents
      </p>
      <div className="flex flex-col items-start justify-start">
        {headings.map((heading, index) => {
          return (
            <button
              key={index}
              type="button"
              className={clsx(
                heading.id === activeId
                  ? "font-medium text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
                  : "font-normal text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200",
                heading.level === 3 && "pl-4",
                "mb-3 text-left text-sm transition-colors hover:underline"
              )}
              onClick={(e) => {
                e.preventDefault();
                document.querySelector(`#${heading.id}`)?.scrollIntoView({
                  behavior: "smooth",
                  block: "start",
                  inline: "nearest",
                });
              }}
            >
              {heading.text}
            </button>
          );
        })}
      </div>
    </div>
  );
};

export default TableOfContents;
```

The logic is mostly modified from [EkomEnyong.com](https://ekomenyong.com/posts/react-nextjs-developer-tech-stack-tools) blog's [TOC.tsx file](https://github.com/ekomenyong/kommy-mdx/blob/main/src/components/TOC.tsx), and the styles are inspired by [Tailwind Nextjs Starter Blog](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing) ([repo](https://github.com/timlrx/tailwind-nextjs-starter-blog)).

Finally, modify `src/components/PostLayout.tsx` to use it:

```tsx
import { useRouter } from "next/router";

import CustomLink from "@/components/CustomLink";
import PageTitle from "@/components/PageTitle";
import PostBody from "@/components/PostBody";
import TableOfContents from "@/components/TableOfContents";
import formatDate from "@/lib/formatDate";

export interface PostForPostLayout {
  date: string;
  title: string;
  body: { raw: string };
}

// ...

export default function PostLayout({
  post,
  nextPost,
  prevPost,
  children,
}: Props) {
  const {
    date,
    title,
    body: { raw },
  } = post;

  const { locale } = useRouter();

  return (
    <article>
      <div className="divide-y divide-gray-200 transition-colors dark:divide-gray-700">
        // ...
        <div
          className="pb-8 transition-colors lg:grid lg:grid-cols-4 lg:gap-x-6"
          style={{ gridTemplateRows: "auto 1fr" }}
        >
          <div className="divide-y divide-gray-200 pt-10 pb-8 transition-colors dark:divide-gray-700 lg:col-span-3">
            <PostBody>{children}</PostBody>
          </div>

          {/* DESKTOP TABLE OF

 CONTENTS */}
          <aside>
            <div className="hidden lg:sticky lg:top-24 lg:col-span-1 lg:block">
              <TableOfContents source={raw} />
            </div>
          </aside>
        </div>
        // ...
      </div>
    </article>
  );
}
```

## Results

Done! Use `pnpm dev`, and when you view an article page on a desktop browser, you will see the table of contents added to the side!

All `h2` and `h3` subheadings will be displayed, with the current reading section highlighted in red as you scroll through the page, and clicking on a title will scroll to the specified section.

The final effect is as follows:

![table of contents dark](https://i.imgur.com/W6Zsfzs.jpg)

![table of contents light](https://i.imgur.com/FLUBZfe.jpg)

The code changes for this article are as follows:

https://github.com/eason-dev/nextjs-tailwind-contentlayer-blog-starter/compare/day23-nprogress...day24-table-of-contents

## References

- [kommy-mdx/TOC.tsx at main · ekomenyong/kommy-mdx](https://github.com/ekomenyong/kommy-mdx/blob/main/src/components/TOC.tsx)
- [timlrx/tailwind-nextjs-starter-blog: This is a Next.js, Tailwind CSS blogging starter template. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.](https://github.com/timlrx/tailwind-nextjs-starter-blog)
- [Flet/github-slugger: Generate a slug just like GitHub does for markdown headings.](https://github.com/Flet/github-slugger)

## Next Article

Congratulations on successfully adding a sidebar table of contents, providing readers with a clear overview of the article structure!

In the next article, we will continue to add another dazzling and useful feature: Comment functionality!
