今日はよくあるTOCを作ってみることにした。
私は現在、マークダウンで書いた記事をnext-mdx-remoteでコンパイルして表示している。
rehype-slugで既にタイトルタグごとにIDが付与されているので、半分は準備が整っている。
まずはuseEffectとquerySelectorAllで簡単に作ってみよう。
1. 簡単にTOCを作ってみる

何か初めて試みることなので、道に迷ったときは質問する方法を知っていることも能力である。
アドバイスをくれる人がいない独学者の私は、ルィテン、クロード、GPTのような大きな兄さんたちにアドバイスを求めるのが最も速い方法。
大きな兄さんたちからもらったコードを少し修正して使ってみた。
"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);
// まずコンポーネントがロードされたら1回実行する。
useEffect(() => {
// querySelectorAllでh2, h3, h4レベルのタグを探す。
const hTags = document.querySelectorAll<HTMLHeadingElement>("h2, h3, h4");
// 要素を配列に変換する。
const headings = [...hTags];
if (headings.length == 0) {
return;
}
setToc(true);
// 要素からTOCを作るために必要な内容のみを抽出
const allHeadings = headings.map((heading) => ({
text: heading.innerText,
id: heading.id || heading.innerText.replace(/\s+/g, "-").toLowerCase(),
tagName: heading.tagName,
}));
setHeadings(allHeadings);
}, []);
return (
toc && (
// TOCは記事のすぐ横に来るようにcalcで位置を計算した。
<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) => (
// どうせh4レベルまで入ることはないので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`}
>
// タイトルに1. 2.のような数字を常に付けるのでreplaceと
正規表現で削除する。
{heading.text.replace(/^\d+\.\s*/, "")}
</a>
</li>
))}
</ul>
</div>
</div>
)
);
}そしてglobals.cssでスクロールにsmoothオプションを付けると、移動時にスクロールがスムーズになる。
どうせカクカクしたスクロールはあまり使い道がないので、男らしくグローバルに組み込んだ。
@layer base {
html {
scroll-behavior: smooth;
}
}クリックテストをしてみた。
うまくいく。

次にintersection observerでビューポイントに入ったタグを強調しよう。
2. Intersection Observer
関数に入るパラメータは以下の通り。
const options = {
root: null, // デフォルトはビューポート。基準となるhtmlelement登録可能。
rootMargin: '0px', // ビューポートの境界設定。
threshold: 0.5 // 50%がビューポートに入るとコールバックを呼び出し
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id}が見える。`);
} else {
console.log('${entry.target.id}が見える。');
}
});
}, options);
// 観察する要素を選択
const target = document.querySelector('#target');
observer.observe(target);useRefでオブザーバー変数を1つ作り、ここにインタラクションオブザーバーを割り当てよう。
コードはssoon-mさんのvelogを参考にした。
export default function TOC() {
...
const observer = useRef<IntersectionObserver>();
const [activeToc, setActiveToc] = useState("");
useEffect(() => {
...
// observer.currentでインタラクションオブザーバーを割り当て
observer.current = new IntersectionObserver(
(entries) => {
// 監視する要素の中でインタラクションしている要素のIDをactiveTocに登録
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
setActiveToc(entry.target.id);
});
},
{
// 最上段から最下段で-70%の地点までを監視
rootMargin: "0px 0px -70% 0px",
threshold: 0.8,
}
);
...
const allHeadings = headings.map((heading) => {
//インタラクションオブザーバーで要素を監視する。
observer.current?.observe(heading);
...
});
...
// returnに匿名関数を呼び出してcleanup関数を割り当て。
return () => observer.current?.disconnect();
}, []);
return (
toc && (
...
// 適当に現在のタイトルを強調するクラスを付与
{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"
}`}
>
...
);
}このようにした後、スライドしながらTOCを確認してみた。
クリックやスクロールに応じてうまく反応する。

3. レビュー
SSRでTOCを作ってみたい気もしたが、反応型なのでクライアント側で生成するのが精神的にいいと悟った。
そして他のブログでTOCが付いているのを見て、いつか自分もやってみようと思っていたが、こうして作ることができて嬉しい。
次は投稿をすべてデータベースに入れる作業を試してみたい。
댓글을 불러오는 중...