Skip to main content
Back to Blog
Tutorials
3 min read
November 7, 2024

How to Build a Dynamic Table of Contents in Next.js

Add an auto-generated table of contents to your blog with scroll-spy highlighting. Parse headings, smooth scroll, and active section tracking.

Ryel Banfield

Founder & Lead Developer

A table of contents improves navigation for long-form content and boosts SEO. Here is how to build one with scroll-spy highlighting.

Step 1: Extract Headings from Markdown

// lib/toc.ts
export interface TocItem {
  id: string;
  text: string;
  level: number;
}

export function extractHeadings(content: string): TocItem[] {
  const headingRegex = /^(#{2,3})\s+(.+)$/gm;
  const headings: TocItem[] = [];
  let match;

  while ((match = headingRegex.exec(content)) !== null) {
    const text = match[2].trim();
    const id = text
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/(^-|-$)/g, "");

    headings.push({
      id,
      text,
      level: match[1].length,
    });
  }

  return headings;
}

Step 2: Table of Contents Component

// components/TableOfContents.tsx
"use client";

import { useState, useEffect } from "react";
import type { TocItem } from "@/lib/toc";

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

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

    headings.forEach(({ id }) => {
      const el = document.getElementById(id);
      if (el) observer.observe(el);
    });

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

  if (headings.length === 0) return null;

  return (
    <nav aria-label="Table of contents" className="space-y-1">
      <p className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
        On this page
      </p>
      <ul className="space-y-1">
        {headings.map((heading) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              onClick={(e) => {
                e.preventDefault();
                document.getElementById(heading.id)?.scrollIntoView({
                  behavior: "smooth",
                });
                setActiveId(heading.id);
              }}
              className={`block border-l-2 py-1 text-sm transition-colors ${
                heading.level === 3 ? "pl-6" : "pl-3"
              } ${
                activeId === heading.id
                  ? "border-blue-600 font-medium text-blue-600"
                  : "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
              }`}
            >
              {heading.text}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Step 3: Blog Post Layout with Sidebar TOC

// app/(site)/blog/[slug]/page.tsx
import { extractHeadings } from "@/lib/toc";
import { TableOfContents } from "@/components/TableOfContents";

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
  const headings = extractHeadings(post.content);

  return (
    <div className="mx-auto max-w-7xl px-4 py-12">
      <div className="grid grid-cols-1 gap-12 lg:grid-cols-[1fr_250px]">
        {/* Main Content */}
        <article className="prose prose-gray max-w-none dark:prose-invert">
          <h1>{post.title}</h1>
          <div dangerouslySetInnerHTML={{ __html: post.html }} />
        </article>

        {/* Sidebar TOC */}
        <aside className="hidden lg:block">
          <div className="sticky top-24">
            <TableOfContents headings={headings} />
          </div>
        </aside>
      </div>
    </div>
  );
}

Step 4: Mobile Table of Contents (Collapsible)

"use client";

import { useState } from "react";
import type { TocItem } from "@/lib/toc";

export function MobileTableOfContents({ headings }: { headings: TocItem[] }) {
  const [open, setOpen] = useState(false);

  if (headings.length === 0) return null;

  return (
    <div className="mb-8 rounded-lg border p-4 lg:hidden dark:border-gray-700">
      <button
        onClick={() => setOpen(!open)}
        className="flex w-full items-center justify-between text-sm font-semibold"
      >
        Table of Contents
        <ChevronDownIcon
          className={`h-4 w-4 transition ${open ? "rotate-180" : ""}`}
        />
      </button>
      {open && (
        <ul className="mt-3 space-y-2 border-t pt-3 dark:border-gray-700">
          {headings.map((heading) => (
            <li key={heading.id}>
              <a
                href={`#${heading.id}`}
                onClick={() => setOpen(false)}
                className={`block text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white ${
                  heading.level === 3 ? "pl-4" : ""
                }`}
              >
                {heading.text}
              </a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Step 5: Progress Indicator

"use client";

import { useState, useEffect } from "react";

export function ReadingProgress() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    function handleScroll() {
      const scrollTop = window.scrollY;
      const docHeight = document.documentElement.scrollHeight - window.innerHeight;
      setProgress(docHeight > 0 ? (scrollTop / docHeight) * 100 : 0);
    }

    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <div className="fixed left-0 top-0 z-50 h-0.5 w-full bg-gray-200 dark:bg-gray-800">
      <div
        className="h-full bg-blue-600 transition-[width] duration-100"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}

Step 6: Heading IDs for Markdown

When rendering Markdown, ensure headings get IDs:

// With rehype-slug plugin
import rehypeSlug from "rehype-slug";

// In your MDX or markdown processor config
const processor = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeSlug) // Adds IDs to headings
  .use(rehypeStringify);

Or manually in a custom heading component:

function Heading({
  level,
  children,
}: {
  level: 1 | 2 | 3;
  children: React.ReactNode;
}) {
  const text = typeof children === "string" ? children : "";
  const id = text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");

  const Tag = `h${level}` as const;
  return (
    <Tag id={id} className="scroll-mt-24">
      <a href={`#${id}`} className="no-underline hover:underline">
        {children}
      </a>
    </Tag>
  );
}

Need Better Blog UX?

We build blogs and content platforms with professional navigation, reading progress, and user experience features. Contact us to get started.

table of contentsblogNext.jsscroll spytutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles