12 / Docs
Table of Contents
An outline of the page that keeps up with the reader, marking the section they are in as they scroll.
Install
Scroll this panel to watch the outline keep up. Sections here are deliberately uneven, which is what makes the tracking worth doing properly.
The outline marks whichever heading was passed most recently.
Usage
Scroll this panel to watch the outline keep up. Sections here are deliberately uneven, which is what makes the tracking worth doing properly.
The outline marks whichever heading was passed most recently.
API
Scroll this panel to watch the outline keep up. Sections here are deliberately uneven, which is what makes the tracking worth doing properly.
The outline marks whichever heading was passed most recently.
"use client"
import * as React from "react"
import { TableOfContents } from "mischief-ui/table-of-contents"
const sections = [
{ id: "toc-demo-install", label: "Install" },
{ id: "toc-demo-usage", label: "Usage" },
{ id: "toc-demo-api", label: "API" },
]
export function TableOfContentsDemo() {
return (
<div className="grid w-full max-w-lg gap-4 sm:grid-cols-[1fr_9rem]">
<div className="border-border h-64 overflow-y-auto rounded-[var(--radius)] border p-4">
{sections.map((section) => (
<section key={section.id} id={section.id} className="mb-6 last:mb-0">
<h3 className="text-sm font-semibold">{section.label}</h3>
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
Scroll this panel to watch the outline keep up. Sections here are
deliberately uneven, which is what makes the tracking worth doing
properly.
</p>
<p className="text-muted-foreground mt-2 text-sm leading-relaxed">
The outline marks whichever heading was passed most recently.
</p>
</section>
))}
</div>
<TableOfContents className="hidden sm:grid" sections={sections} />
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contentsimport { TableOfContents } from "mischief-ui/table-of-contents"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type TocSection = { id: string label: string} export type TableOfContentsProps = Omit< React.HTMLAttributes<HTMLElement>, "children"> & {Usage
const sections = [
{ id: "install", label: "Install" },
{ id: "usage", label: "Usage" },
]
export function Outline() {
return <TableOfContents sections={sections} />
}How the current section is chosen
On every scroll the component reads where each heading is and marks the last one to have passed a line near the top of the viewport. That line is offset, which defaults to 96 pixels -- set it to roughly the height of whatever sits fixed above your content, or headings will highlight while still hidden behind it.
This is deliberately position tracking rather than an intersection observer. An observer only reports as a heading crosses an edge, so a heading scrolled past between two callbacks leaves the wrong entry marked, and the index reads a section behind the page. Reading positions costs a little more and is never wrong.
<TableOfContents
sections={[
{ id: "install", label: "Install" },
{ id: "usage", label: "Usage" },
]}
offset={72}
onActiveChange={setCurrent}
/>If you use smooth scrolling
With scroll-behavior set to smooth, a click on an entry animates to the heading, and the marked section changes several times on the way as each heading passes the line. That is correct, and it is also why measuring the active entry immediately after a click tells you where the page was, not where it is going.
API
sectionsTocSection[]The id of each section and the label to show for it.offsetnumberHow far below the top a heading counts as reached. Defaults to 96.labelstringThe accessible name and the visible heading.onActiveChange(id: string | null) => voidRuns when the reader moves into another section.TocSection
idstringThe id of the element this entry points at.labelstringHow the section is named in the index.Accessibility
The current entry is marked with aria-current, so its position is announced rather than shown only in weight. Sections on a documentation page are tall and very uneven, so this tracks the heading most recently scrolled past instead of observing which box intersects a band, which selects several at once or none. The final section is often too short to reach the line, so the bottom of the page selects it outright.