Mischief

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.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/table-of-contents
import { 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.

registry/default/table-of-contents/table-of-contents.tsx
"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}
/>
Every id must belong to a real element; one that does not is simply never marked.

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.