Table Of Contents

Auto-generated table of contents from page headings. Install Table Of Contents from the VLLNT UI registry with the shadcn CLI.

Report a bug

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.json

Source

"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.

Dependencies

  • @vllnt/ui@^0.3.0