Today I decided to create a TOC that everyone seems to have.
Right now I’m compiling posts written in Markdown with next-mdx-remote and rendering them.
Since rehype-slug is already assigning IDs to every heading tag, half the work is already done.
First, let’s quickly build one using useEffect and querySelectorAll.
1. Making a simple TOC

When you’re trying something for the first time and can’t find your way, knowing how to ask questions is also a skill.
For self-taught people like me who don’t have anyone to ask, the fastest way is to ask the big bros like Ruitten, Claude, and GPT for advice.
I slightly modified the code I got from the big bros and used it.
"use client";
import { useEffect, useState } from "react";
interface StringKeyValue {
[key: string]: string;
}
export default function TOC() {
const [headings, setHeadings] = useState<StringKeyValue[]>([]);
const [toc, setToc] = useState(false);
// First, run this once when the component loads.
useEffect(() => {
// Find h2, h3, h4 level tags with querySelectorAll.
const hTags = document.querySelectorAll<HTMLHeadingElement>("h2, h3, h4");
// Turn the NodeList into an array.
const headings = [...hTags];
if (headings.length == 0) {
return;
}
setToc(true);
// Extract only the data needed to build the TOC from the elements
const allHeadings = headings.map((heading) => ({
text: heading.innerText,
id: heading.id || heading.innerText.replace(/\s+/g, "-").toLowerCase(),
tagName: heading.tagName,
}));
setHeadings(allHeadings);
}, []);
return (
toc && (
// Using calc, position the TOC right next to the post.
<div className="fixed hidden xl:block top-40 w-64 p-4 left-[calc(50%+390px)]">
<div className="text-2xl mb-4">Contents</div>
<div className="pl-2">
<ul className="prose">
{headings.map((heading) => (
// I’m not planning to go down to h4 anyway, so I only set a class up to h3.
<li
key={heading.id}
className={`${
heading.tagName == "H3" && "pl-6"
} border-l-2 border-gray-200`}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2`}
>
// I always prefix headings with numbers like 1. 2. etc.,
so I remove them with replace and a regex.
{heading.text.replace(/^\d+\.\s*/, "")}
</a>
</li>
))}
</ul>
</div>
</div>
)
);
}And if you add the smooth option for scrolling in globals.css, scrolling becomes smoother when navigating.
There’s not much use for stiff scrolling anyway, so I just shoved it into global like a real man.
@layer base {
html {
scroll-behavior: smooth;
}
}I tested the clicks.
Works well.

Now let’s use an Intersection Observer to highlight the headings that enter the viewport.
2. Intersection Observer
The parameters for the function are as follows:
const options = {
root: null, // Default is the viewport. You can register an HTMLElement as the root.
rootMargin: '0px', // Sets the viewport margin.
threshold: 0.5 // Calls the callback when 50% is inside the viewport
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} is visible.`);
} else {
console.log('${entry.target.id} is visible.');
}
});
}, options);
// Select the element to observe
const target = document.querySelector('#target');
observer.observe(target);Create an observer variable with useRef and assign the Intersection Observer to it.
I referred to ssoon-m’s velog post for the code.
export default function TOC() {
...
const observer = useRef<IntersectionObserver>();
const [activeToc, setActiveToc] = useState("");
useEffect(() => {
...
// Assign the Intersection Observer to observer.current
observer.current = new IntersectionObserver(
(entries) => {
// Among the observed elements, register the ID of the one currently intersecting as activeToc
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
setActiveToc(entry.target.id);
});
},
{
// Observe the area between the very top and the point that is -70% from the bottom
rootMargin: "0px 0px -70% 0px",
threshold: 0.8,
}
);
...
const allHeadings = headings.map((heading) => {
// Observe each element with the Intersection Observer.
observer.current?.observe(heading);
...
});
...
// In the return, assign a cleanup function by returning an anonymous function.
return () => observer.current?.disconnect();
}, []);
return (
toc && (
...
// Roughly add a class to highlight the current heading
{headings.map((heading) => (
<li
key={heading.id}
className={`${
heading.tagName === "H3" && "pl-6"
} ${activeToc == heading.id ? "border-l-4 border-gray-500":"border-l-2 border-gray-200"} `}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2 ${
activeToc == heading.id ? "text-black font-black" : "text-gray-500"
}`}
>
...
);
}After doing this, I scrolled through the page and checked the TOC.
It reacts well to both clicks and scrolls.

3. Thoughts
I wanted to try building the TOC with SSR, but I realized that since it needs to be responsive, generating it on the client side is better for my sanity.
Also, I’d been seeing TOCs on other blogs and thinking “I should implement that someday,” and I’m happy that I finally got around to making it.
Next time, I’m going to try putting all my posts into a database.
댓글을 불러오는 중...