ナビゲーション
Table of Contents
記事内の見出しをナビゲートする目次コンポーネント。IntersectionObserver でスクロール位置と連動し、アクティブ項目をハイライト。
インストール
npx shadcn add https://d-ui.daigo-suhara.com/registry/table-of-contents.jsonサンプル
使い方
import { TableOfContents } from "@/components/ui/table-of-contents"
export default function ArticlePage() {
return (
<div className="flex gap-8">
<article className="flex-1">
<h2 id="intro">はじめに</h2>
<h2 id="setup">セットアップ</h2>
<h3 id="install">インストール</h3>
<h2 id="usage">使い方</h2>
</article>
<aside className="w-48">
<TableOfContents
items={[
{ id: "intro", label: "はじめに" },
{ id: "setup", label: "セットアップ" },
{ id: "install", label: "インストール", level: 2 },
{ id: "usage", label: "使い方" },
]}
/>
</aside>
</div>
)
}プロパティ
items必須{ id: string; label: string; level?: 1 | 2 | 3 }[]目次項目の配列
title任意string目次のタイトル
デフォルト:
"目次"activeId必須string外部から制御するアクティブID(省略時は自動検出)
className必須string追加のCSSクラス
| 名前 | 型 | デフォルト | 説明 | |
|---|---|---|---|---|
| 必須 | items | { id: string; label: string; level?: 1 | 2 | 3 }[] | — | 目次項目の配列 |
| 任意 | title | string | "目次" | 目次のタイトル |
| 必須 | activeId | string | — | 外部から制御するアクティブID(省略時は自動検出) |
| 必須 | className | string | — | 追加のCSSクラス |
ソースコード
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface TocItem {
id: string;
label: string;
level?: 1 | 2 | 3;
}
interface TableOfContentsProps {
items: TocItem[];
title?: string;
activeId?: string;
className?: string;
}
export function TableOfContents({
items,
title = "目次",
activeId: externalActiveId,
className,
}: TableOfContentsProps) {
const [activeId, setActiveId] = React.useState<string>(
externalActiveId ?? items[0]?.id ?? ""
);
React.useEffect(() => {
if (externalActiveId !== undefined) {
setActiveId(externalActiveId);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
break;
}
}
},
{ rootMargin: "0px 0px -70% 0px", threshold: 0 }
);
items.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items, externalActiveId]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
setActiveId(id);
};
return (
<nav className={cn("", className)}>
{title && (
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</p>
)}
<div className="relative border-l border-border">
{items.map((item) => {
const level = item.level ?? 1;
const isActive = activeId === item.id;
return (
<a
key={item.id}
href={`#${item.id}`}
onClick={(e) => handleClick(e, item.id)}
className={cn(
"group relative flex items-start gap-2 py-1.5 pl-4 text-sm leading-snug transition-colors duration-150",
level === 2 && "pl-7",
level === 3 && "pl-10 text-xs",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{/* left track indicator */}
<span
className={cn(
"absolute -left-px top-0 h-full w-0.5 rounded-full transition-colors duration-150",
isActive ? "bg-primary" : "bg-transparent group-hover:bg-border"
)}
/>
<span className={cn(isActive && "font-medium")}>{item.label}</span>
</a>
);
})}
</div>
</nav>
);
}