Table Of Contents
Auto-generated table of contents from page headings. Install Table Of Contents from the VLLNT UI registry with the shadcn CLI.
Auto-generated table of contents from page headings. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Table Of Contents to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/table-of-contents.jsonSource
"use client";
import { useEffect, useState } from "react";
import type { HeadingTag } from "../../lib/types";
import { cn } from "../../lib/utils";
type TableOfContentsProps = {
/** Heading tag for the "On This Page" label. Defaults to `h3`. */
as?: HeadingTag;
sections: { id: string; title: string }[];
};
function handleClick(id: string) {
const element = document.querySelector(`#${id}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
function useActiveSection(sections: { id: string; title: string }[]) {
const [activeSection, setActiveSection] = useState<null | string>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
});
},
{
rootMargin: "-100px 0px -66% 0px",
threshold: 0,
},
);
sections.forEach((section) => {
const element = document.querySelector(`#${section.id}`);
if (element) {
observer.observe(element);
}
});
return () => {
sections.forEach((section) => {
const element = document.querySelector(`#${section.id}`);
if (element) {
observer.unobserve(element);
}
});
};
}, [sections]);
return activeSection;
}
export function TableOfContents({
as: Heading = "h3",
sections,
}: TableOfContentsProps) {
const activeSection = useActiveSection(sections);
if (sections.length === 0) {
return null;
}
return (
<aside className="hidden xl:block">
<div className="sticky top-8">
<div className="border-l-2 border-border pl-4">
<Heading className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wider">
On This Page
</Heading>
<nav className="space-y-2">
{sections.map((section) => (
<button
className={cn(
"block text-left text-sm transition-colors",
"hover:text-foreground",
activeSection === section.id
? "text-foreground font-medium"
: "text-muted-foreground",
)}
key={section.id}
onClick={() => {
handleClick(section.id);
}}
type="button"
>
{section.title}
</button>
))}
</nav>
</div>
</div>
</aside>
);
}
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.