# Table of Contents

> Scroll-spy outline that highlights the current section with smooth jump links and nested levels.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/table-of-contents
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/table-of-contents.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/table-of-contents-demo.tsx"
"use client"

import * as React from "react"

import { TableOfContents, type TocHeading } from "@/components/table-of-contents"

const headings: TocHeading[] = [
  { id: "toc-intro", label: "Introduction" },
  { id: "toc-install", label: "Installation" },
  { id: "toc-cli", label: "Using the CLI", level: 3 },
  { id: "toc-usage", label: "Usage" },
  { id: "toc-theming", label: "Theming" },
  { id: "toc-faq", label: "FAQ" },
]

export default function TableOfContentsDemo() {
  const scroller = React.useRef<HTMLDivElement>(null)

  return (
    <div className="flex h-80 w-full max-w-2xl gap-6 overflow-hidden rounded-2xl border bg-card p-4">
      <div
        ref={scroller}
        role="region"
        aria-label="Article"
        tabIndex={0}
        className="min-w-0 flex-1 overflow-y-auto pr-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
      >
        {headings.map((heading) => (
          <section key={heading.id} className="pb-8">
            <h3 id={heading.id} className={heading.level === 3 ? "text-sm font-semibold" : "text-base font-semibold"}>
              {heading.label}
            </h3>
            <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
              Placeholder copy for the “{heading.label}” section. Scroll this column and watch the outline on the right follow along,
              or click an entry to jump straight to it.
            </p>
            <div className="mt-3 h-24 rounded-lg bg-muted/60" />
          </section>
        ))}
        <div className="h-24" />
      </div>
      <TableOfContents headings={headings} scrollTarget={scroller} className="w-40 shrink-0" />
    </div>
  )
}
```

## Source

```tsx title="components/table-of-contents.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type TocHeading = { id: string; label: string; level?: 1 | 2 | 3 }

export interface TableOfContentsProps extends Omit<React.ComponentProps<"nav">, "children" | "title"> {
  headings: TocHeading[]
  title?: string
  /** Scroll container the headings live in; the window when omitted */
  scrollTarget?: React.RefObject<HTMLElement | null>
  /** Distance from the top of the container at which a heading counts as current */
  offset?: number
}

const indent = { 1: "pl-3", 2: "pl-3", 3: "pl-6" } as const

export function TableOfContents({ headings, title = "On this page", scrollTarget, offset = 24, className, ...props }: TableOfContentsProps) {
  const [active, setActive] = React.useState(headings[0]?.id)

  React.useEffect(() => {
    const element = scrollTarget?.current
    const source: HTMLElement | Window = element ?? window
    const update = () => {
      const top = element ? element.getBoundingClientRect().top : 0
      const atBottom = element
        ? element.scrollTop + element.clientHeight >= element.scrollHeight - 2
        : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2
      let current = headings[0]?.id
      for (const heading of headings) {
        const node = document.getElementById(heading.id)
        if (node && node.getBoundingClientRect().top - top <= offset + 1) current = heading.id
      }
      setActive(atBottom ? headings[headings.length - 1]?.id : current)
    }
    const frame = requestAnimationFrame(update)
    source.addEventListener("scroll", update, { passive: true })
    return () => {
      cancelAnimationFrame(frame)
      source.removeEventListener("scroll", update)
    }
  }, [headings, scrollTarget, offset])

  return (
    <nav aria-label={title} className={cn("text-sm", className)} {...props}>
      <p className="mb-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{title}</p>
      <ul className="relative grid border-l">
        {headings.map((heading) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              aria-current={active === heading.id ? "location" : undefined}
              onClick={(event) => {
                event.preventDefault()
                document.getElementById(heading.id)?.scrollIntoView({ behavior: "smooth", block: "start" })
                setActive(heading.id)
              }}
              className={cn(
                "-ml-px block border-l-2 border-transparent py-1.5 pr-2 text-muted-foreground transition-colors hover:text-foreground aria-[current=location]:border-primary aria-[current=location]:font-medium aria-[current=location]:text-foreground focus-visible:text-foreground focus-visible:outline-none",
                indent[heading.level ?? 1]
              )}
            >
              {heading.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  )
}
```
