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.