Table of Contents
Open in
Self-container TOC component inspired by the Fumadocs TOC component.

Features

  • Active anchor tracking using IntersectionObserver
  • Two variants: default (straight line) and clerk (depth-aware line)
  • Smooth auto-scroll within the TOC container when active item changes
  • Animated indicator thumb that highlights active sections
  • Support for nested headings with depth-based indentation
  • Self-contained - no external providers required
  • Fully typed with TypeScript
  • RTL support

Installation

terminal
npx shadcn@latest add "https://crux-ui.vercel.app/r/toc.json"

Usage

Basic Setup

Wrap your page content with TOCProvider and pass an array of TOC items. Then place the PageTOC and PageTOCItems components in your sidebar.

Reactpage.tsx
import {   type TOCItemType,   TOCProvider,   PageTOC,   PageTOCItems } from "@/registry/abui/ui/toc"const tocItems: TOCItemType[] = [  { title: "Introduction", url: "#introduction", depth: 2 },  { title: "Getting Started", url: "#getting-started", depth: 2 },  { title: "Installation", url: "#installation", depth: 3 },  { title: "Configuration", url: "#configuration", depth: 3 },  { title: "Advanced Usage", url: "#advanced-usage", depth: 2 },]export default function Page() {  return (    <TOCProvider toc={tocItems}>      <div className="flex gap-8">        {/* Main content */}        <div className="flex-1">          <section id="introduction">...</section>          <section id="getting-started">...</section>          <section id="installation">...</section>          <section id="configuration">...</section>          <section id="advanced-usage">...</section>        </div>        {/* TOC Sidebar */}        <aside className="w-64 shrink-0">          <PageTOC className="sticky top-20">            <p className="mb-1 font-medium text-sm">On This Page</p>            <PageTOCItems />          </PageTOC>        </aside>      </div>    </TOCProvider>  )}

With Clerk Variant

The clerk variant renders a depth-aware line that follows the hierarchy of your headings. This creates a visual connection between nested items.

Reactclerk-variant.tsx
<PageTOC className="sticky top-20">  <p className="mb-1 font-medium text-sm">On This Page</p>  <PageTOCItems variant="clerk" /></PageTOC>

Variants

Default

A simple straight vertical line with depth-based indentation. The active indicator slides along the line to show the current position.

👉 Look at the TOC on the right side of this page - toggle between variants to see the difference!

Clerk

A depth-aware line that changes horizontal position based on the heading depth. Creates a visual hierarchy with diagonal connectors between different depth levels.

  • Depth ≤ 2: Line at position 0px
  • Depth ≥ 3: Line at position 10px
  • Diagonal SVG lines connect different depth levels
  • SVG mask creates the full path for the active indicator

Component Props

TOCProvider

The main context provider that manages active anchor state.

  • toc - TOCItemType[] - Array of TOC items (required)
  • single - boolean - Only allow one active item at a time (default: false)
  • children - ReactNode - Content to wrap

PageTOCItems

Renders the TOC list with the selected variant style.

  • variant - "default" | "clerk" - Visual style variant (default: "default")
  • emptyText - string - Text shown when no headings (default: "No Headings")

TOCItemType

The shape of each TOC item.

TypeScripttypes.ts
interface TOCItemType {  title: ReactNode    // The text to display  url: string         // The anchor URL (e.g., "#section-id")  depth: number       // Heading depth (2 = h2, 3 = h3, etc.)}

Hooks

The component exports several hooks for advanced usage:

  • useActiveAnchor() - Returns the first active anchor ID
  • useActiveAnchors() - Returns all visible anchor IDs
  • useTOCItems() - Returns the TOC items from context
Reactcustom-indicator.tsx
import { useActiveAnchor } from "@/registry/abui/ui/toc"function CurrentSection() {  const activeAnchor = useActiveAnchor()    return (    <div>      Currently viewing: {activeAnchor || "none"}    </div>  )}