Table of contents with scroll spy
Tables of contents with scroll spy for guides and articles. A sticky rail, a foldable box, an edge minimap and chapter cards.
Always know where you are in the text
A table of contents tells readers what an article covers and where they are in it. All four versions follow the scroll and mark the current section, links jump to the heading, and each one has its own layout for phones.
Sticky sidebar runs beside the text with a gliding marker and a filling line, on phones it becomes a bar that unfolds. Contents box sits numbered under the title, folds, marks what was read and offers a way back. Edge minimap shows quiet ticks at the edge that open into titles on hover or focus, with a progress button on phones. Chapter cards show chapters with summary and minutes, mark them as read while you scroll and offer a resume link.
Use the sidebar for guides over 1500 words, the contents box for medium length articles. The minimap suits texts that want an undisturbed reading column. Chapter cards fit courses and multi part guides that readers return to.
Variants
Details
What Table of contents brings along, read from the registry and the source.
- Category
- Article elements
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/table-of-contents.tsx
- Brings along
- utilskiticonsectionreveal
- npm packages
- None
- Languages
- English and German, 15 texts of its own
- Studio
- Adjust in code
- In collections
- Blog or magazine
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TableOfContentsProps interface from components/ui/table-of-contents.tsx, with the comments from the code.
variantoptionalTableOfContentsVariantsidebarinline-boxminimapchapters
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeentriesrequiredTocEntry[]childrenoptionalReact.ReactNodeYour own article. Without it the paragraphs of `entries` are rendered.
asoptional'h1' | 'h2'Heading level of the title. Sections are one level below, sub sections two.
defaultCollapsedoptionalbooleaninline-box: the box starts folded
Built-in texts
Table of contents writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 15 texts
| Key | English | German |
|---|---|---|
| contents | English Contents | German Inhalt |
| onThisPage | English On this page | German Auf dieser Seite |
| show | English Show contents | German Inhalt zeigen |
| hide | English Hide contents | German Inhalt ausblenden |
| back | English Back to contents | German Zurück zum Inhalt |
| minutes | English {n} min | German {n} Min. |
| read | English {a} of {b} read | German {a} von {b} gelesen |
| progress | English Read so far | German Bisher gelesen |
| resume | English Continue with | German Weiter mit |
| start | English Start reading | German Mit dem Lesen beginnen |
| current | English You are here | German Du bist hier |
| done | English Read | German Gelesen |
| chapter | English Chapter {n} | German Kapitel {n} |
| open | English Open contents | German Inhalt öffnen |
| close | English Close contents | German Inhalt schließen |
Accessibility
Read from the source of the component.
- Table of contents handles the keys Escape itself.
- Sets aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 713 lines
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
/*
* A table of contents for long pages, guides and articles, with scroll spy
* and smooth jumps. Without JavaScript every entry is a plain anchor link.
*
* sidebar a sticky rail beside the text with a marker that follows the
* reader and a line that fills; on phones a bar that unfolds
* inline-box a contents box under the title, numbered and foldable, with
* a way back once it scrolls away
* minimap quiet ticks at the edge that open into titles on hover or
* focus; on phones a round button with the reading progress
* chapters chapter cards with summary and minutes that mark what was
* read, and a resume link
*
* The text comes from `entries` (title and paragraphs per section) or as
* your own article in `children`, then the ids of `entries` must exist in it.
*/
export type TableOfContentsVariant = 'sidebar' | 'inline-box' | 'minimap' | 'chapters';
export interface TocEntry {
/** Anchor id of the section, unique on the page */
id: string;
title: string;
/** Paragraphs, rendered when there are no children */
body?: string[];
/** chapters: one sentence about the chapter */
summary?: string;
/** Reading time in minutes */
minutes?: number;
/** Sub sections, one level deeper */
children?: TocEntry[];
}
const TEXTS = {
en: {
contents: 'Contents',
onThisPage: 'On this page',
show: 'Show contents',
hide: 'Hide contents',
back: 'Back to contents',
minutes: '{n} min',
read: '{a} of {b} read',
progress: 'Read so far',
resume: 'Continue with',
start: 'Start reading',
current: 'You are here',
done: 'Read',
chapter: 'Chapter {n}',
open: 'Open contents',
close: 'Close contents',
},
de: {
contents: 'Inhalt',
onThisPage: 'Auf dieser Seite',
show: 'Inhalt zeigen',
hide: 'Inhalt ausblenden',
back: 'Zurück zum Inhalt',
minutes: '{n} Min.',
read: '{a} von {b} gelesen',
progress: 'Bisher gelesen',
resume: 'Weiter mit',
start: 'Mit dem Lesen beginnen',
current: 'Du bist hier',
done: 'Gelesen',
chapter: 'Kapitel {n}',
open: 'Inhalt öffnen',
close: 'Inhalt schließen',
},
};
type Texts = (typeof TEXTS)['en'];
export interface TableOfContentsProps {
variant?: TableOfContentsVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
entries: TocEntry[];
/** Your own article. Without it the paragraphs of `entries` are rendered. */
children?: React.ReactNode;
/** Heading level of the title. Sections are one level below, sub sections two. */
as?: 'h1' | 'h2';
/** inline-box: the box starts folded */
defaultCollapsed?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
type Flat = { id: string; title: string; level: 1 | 2; number: string; parent?: string };
function flatten(entries: TocEntry[]): Flat[] {
const out: Flat[] = [];
entries.forEach((e, i) => {
out.push({ id: e.id, title: e.title, level: 1, number: String(i + 1) });
e.children?.forEach((c, j) => out.push({ id: c.id, title: c.title, level: 2, number: `${i + 1}.${j + 1}`, parent: e.id }));
});
return out;
}
/**
* The id of the section the reader is in (the last one whose heading passed
* 30 % of the window) and how far the article is read, 0 to 1. The first
* section and 0 on the server, so the markup matches.
*/
function useScrollSpy(ids: string[], article: React.RefObject<HTMLElement | null>) {
const [active, setActive] = useState(ids[0] ?? '');
const [progress, setProgress] = useState(0);
const key = ids.join('|');
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const line = window.innerHeight * 0.3;
let current = ids[0] ?? '';
for (const id of ids) {
const el = document.getElementById(id);
if (el && el.getBoundingClientRect().top <= line) current = id;
}
const a = article.current;
if (a) {
const r = a.getBoundingClientRect();
const atEnd = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
const p = atEnd && r.top < window.innerHeight ? 1 : Math.min(1, Math.max(0, (line - r.top) / Math.max(1, r.height - line)));
setProgress(round(p, 3));
if (atEnd && r.bottom <= window.innerHeight + 2) current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return { active, progress };
}
/** Smooth jump to a section, the heading takes the focus, the address keeps the anchor */
function useJump(after?: () => void) {
return useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const el = document.getElementById(id);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' });
history.replaceState(null, '', `#${id}`);
const h = el.querySelector<HTMLElement>('h2, h3, h4, h5') ?? el;
h.setAttribute('tabindex', '-1');
h.focus({ preventScroll: true });
after?.();
},
[after],
);
}
const PROSE = 'max-w-[36rem] space-y-5 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))]';
/** The article from `entries`, one heading level below the title */
function Article({ entries, level, className }: { entries: TocEntry[]; level: 2 | 3; className?: string }) {
const H = `h${level}` as 'h2' | 'h3';
const H2 = `h${level + 1}` as 'h3' | 'h4';
return (
<div className={cn('space-y-16', className)}>
{entries.map((e) => (
<section key={e.id} id={e.id} aria-labelledby={`${e.id}-h`} className="scroll-mt-24">
<H id={`${e.id}-h`} className="max-w-[36rem] text-balance text-[clamp(1.5rem,1.3rem+0.6vw,1.875rem)] font-semibold leading-tight tracking-[-0.025em] text-foreground outline-none">
{e.title}
</H>
{e.body && (
<div className={cn(PROSE, 'mt-5')}>
{e.body.map((b, i) => (
<p key={i}>{b}</p>
))}
</div>
)}
{e.children?.map((c) => (
<section key={c.id} id={c.id} aria-labelledby={`${c.id}-h`} className="mt-10 scroll-mt-24">
<H2 id={`${c.id}-h`} className="max-w-[36rem] text-balance text-[19px] font-semibold tracking-[-0.015em] text-foreground outline-none">
{c.title}
</H2>
{c.body && (
<div className={cn(PROSE, 'mt-3')}>
{c.body.map((b, i) => (
<p key={i}>{b}</p>
))}
</div>
)}
</section>
))}
</section>
))}
</div>
);
}
function Header({ p, center = false, className }: { p: TableOfContentsProps; center?: boolean; className?: string }) {
const Tag = p.as ?? 'h2';
if (!p.title && !p.eyebrow && !p.description) return null;
return (
<header className={cn('mb-12 max-w-[44rem]', center && 'mx-auto text-center', className)}>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <Tag className={cn('text-balance font-semibold text-foreground', Tag === 'h1' ? 'text-h1' : 'text-h2')}>{p.title}</Tag>}
{p.description && <p className={cn('mt-5 text-pretty text-lead text-muted-foreground', center && 'mx-auto max-w-[36rem]')}>{p.description}</p>}
</header>
);
}
const fill = (s: string, vars: Record<string, string | number>) => Object.entries(vars).reduce((a, [k, v]) => a.replace(`{${k}}`, String(v)), s);
/* ------------------------------------------------------------------ */
/* Sidebar */
/* ------------------------------------------------------------------ */
function Sidebar({ p, t }: { p: TableOfContentsProps; t: Texts }) {
const flat = flatten(p.entries);
const article = useRef<HTMLDivElement>(null);
const { active, progress } = useScrollSpy(
flat.map((f) => f.id),
article,
);
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
const jump = useJump(close);
const toggle = useRef<HTMLButtonElement>(null);
const list = useRef<HTMLOListElement>(null);
const [marker, setMarker] = useState<{ top: number; height: number } | null>(null);
const panel = useId();
const locale = useLocale();
const level = p.as === 'h1' ? 2 : 3;
const activeEntry = flat.find((f) => f.id === active) ?? flat[0];
const activeTop = activeEntry?.parent ?? activeEntry?.id;
useLayoutEffect(() => {
const place = () => {
const on = list.current?.querySelector<HTMLElement>('[aria-current="location"]');
if (!on || !list.current) return;
const a = list.current.getBoundingClientRect();
const b = on.getBoundingClientRect();
setMarker({ top: Math.round(b.top - a.top), height: Math.round(b.height) });
};
place();
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [active]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
setOpen(false);
toggle.current?.focus();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
return (
<>
<Header p={p} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-16 xl:grid-cols-[17rem_minmax(0,1fr)]">
<nav aria-label={t.contents} className="sticky top-0 z-20 -mx-6 self-start lg:top-24 lg:mx-0">
{/* phones and tablets: a bar with the current section that unfolds into the list */}
<div className="border-b border-border bg-background/90 backdrop-blur-md lg:hidden">
<button
ref={toggle}
type="button"
aria-expanded={open}
aria-controls={panel}
onClick={() => setOpen((o) => !o)}
className="flex min-h-14 w-full cursor-pointer items-center gap-3 px-6 text-left outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring"
>
<span className="relative flex size-8 shrink-0 items-center justify-center" aria-hidden>
<svg viewBox="0 0 32 32" className="absolute inset-0 -rotate-90">
<circle cx="16" cy="16" r="13" fill="none" strokeWidth="2.5" className="stroke-track" />
<circle cx="16" cy="16" r="13" fill="none" strokeWidth="2.5" strokeLinecap="round" className="stroke-primary transition-[stroke-dashoffset] duration-300" strokeDasharray={round(2 * Math.PI * 13)} strokeDashoffset={round(2 * Math.PI * 13 * (1 - progress))} />
</svg>
<Icon name="list" size={13} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[12px] text-muted-foreground">{t.onThisPage}</span>
<span key={activeEntry?.id} className="block truncate text-[14px] font-medium text-foreground motion-safe:animate-[kit-rise_0.35s_var(--ease-kit)]">
{activeEntry?.title}
</span>
</span>
<Icon name="chevron-down" size={18} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
</button>
<div id={panel} hidden={!open} className="max-h-[60vh] overflow-y-auto border-t border-border px-3 pb-3 pt-2">
<ol className="space-y-0.5">
{flat.map((f) => (
<li key={f.id}>
<a
href={`#${f.id}`}
onClick={(e) => jump(e, f.id)}
aria-current={active === f.id ? 'location' : undefined}
className={cn(
'flex min-h-11 items-center gap-3 rounded-lg px-3 text-[14px] transition-colors outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring',
f.level === 2 && 'pl-9 text-[13.5px]',
active === f.id ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
)}
>
{f.level === 1 && <span className="w-4 shrink-0 text-[12px] tabular-nums text-subtle-foreground">{f.number}</span>}
<span className="min-w-0">{f.title}</span>
</a>
</li>
))}
</ol>
</div>
</div>
{/* desktop: the rail */}
<div className="hidden lg:block">
<p className="mb-4 text-[12px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">{t.onThisPage}</p>
<div className="relative">
<span aria-hidden className="absolute bottom-0 left-0 top-0 w-px bg-border" />
<span aria-hidden className="absolute left-0 top-0 w-px origin-top bg-[color-mix(in_oklab,var(--primary)_45%,var(--border))] transition-transform duration-300 motion-reduce:transition-none" style={{ height: '100%', transform: `scaleY(${progress})` }} />
{marker && <span aria-hidden className="absolute -left-px w-[3px] rounded-full bg-primary transition-[top,height] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ top: marker.top, height: marker.height }} />}
<ol ref={list} className="space-y-0.5">
{flat.map((f) => {
const on = active === f.id;
const inBranch = f.level === 2 && f.parent !== activeTop;
return (
<li key={f.id} className={cn(inBranch && 'hidden')}>
<a
href={`#${f.id}`}
onClick={(e) => jump(e, f.id)}
aria-current={on ? 'location' : undefined}
className={cn(
'block rounded-r-md py-1.5 pl-4 pr-2 text-[14px] leading-snug transition-colors outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring',
f.level === 2 && 'pl-8 text-[13.5px]',
on ? 'font-medium text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{f.title}
</a>
</li>
);
})}
</ol>
</div>
<div className="mt-8 flex items-center justify-between border-t border-border pt-4 text-[13px] text-muted-foreground">
<span>{t.progress}</span>
<span className="tabular-nums text-foreground">{formatPercent(Math.round(progress * 100), locale)}</span>
</div>
</div>
</nav>
<div ref={article} className="min-w-0">
{p.children ?? <Article entries={p.entries} level={level as 2 | 3} />}
</div>
</div>
</>
);
}
/* ------------------------------------------------------------------ */
/* Inline box */
/* ------------------------------------------------------------------ */
function InlineBox({ p, t }: { p: TableOfContentsProps; t: Texts }) {
const flat = flatten(p.entries);
const article = useRef<HTMLDivElement>(null);
const box = useRef<HTMLElement>(null);
const { active } = useScrollSpy(
flat.map((f) => f.id),
article,
);
const [open, setOpen] = useState(!p.defaultCollapsed);
const [away, setAway] = useState(false);
const jump = useJump();
const panel = useId();
const level = p.as === 'h1' ? 2 : 3;
const activeIndex = flat.findIndex((f) => f.id === active);
const total = p.entries.reduce((s, e) => s + (e.minutes ?? 0), 0);
useEffect(() => {
const el = box.current;
const a = article.current;
if (!el || !a) return;
const update = () => {
const r = el.getBoundingClientRect();
const ar = a.getBoundingClientRect();
setAway(r.bottom < 0 && ar.bottom > window.innerHeight * 0.6);
};
update();
window.addEventListener('scroll', update, { passive: true });
return () => window.removeEventListener('scroll', update);
}, []);
const backToBox = (e: React.MouseEvent<HTMLAnchorElement>) => {
const el = box.current;
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: prefersReducedMotion() ? 'auto' : 'smooth', block: 'start' });
setOpen(true);
el.querySelector<HTMLElement>('button')?.focus({ preventScroll: true });
};
return (
<div className="mx-auto max-w-[44rem]">
<Header p={p} className="mb-10" />
<Reveal effect="rise">
<nav ref={box} id={`${panel}-box`} aria-label={t.contents} className="scroll-mt-6 overflow-hidden rounded-xl border border-border bg-surface shadow-[var(--highlight)]">
<div className="flex items-center justify-between gap-4 py-2 pl-5 pr-2 sm:pl-6">
<p className="flex items-baseline gap-3 text-[15px] font-semibold text-foreground">
{t.contents}
{total > 0 && <span className="text-[13px] font-normal text-muted-foreground">{fill(t.minutes, { n: total })}</span>}
</p>
<button
type="button"
aria-expanded={open}
aria-controls={panel}
onClick={() => setOpen((o) => !o)}
className="inline-flex min-h-11 cursor-pointer items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring"
>
{open ? t.hide : t.show}
<Icon name="chevron-down" size={15} className={cn('transition-transform duration-300', open && 'rotate-180')} />
</button>
</div>
<div id={panel} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className={cn('min-h-0 overflow-hidden', !open && 'invisible')}>
<ol className="border-t border-border px-2 py-2 sm:columns-2 sm:gap-2 sm:px-3">
{flat.map((f, i) => {
const on = f.id === active;
const read = activeIndex > i;
const entry = f.level === 1 ? p.entries.find((e) => e.id === f.id) : undefined;
return (
<li key={f.id} className="break-inside-avoid">
<a
href={`#${f.id}`}
onClick={(e) => jump(e, f.id)}
aria-current={on ? 'location' : undefined}
className={cn(
'group flex min-h-11 items-center gap-3 rounded-lg px-3 py-2 text-[14.5px] leading-snug transition-colors outline-none hover:bg-accent focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring',
f.level === 2 && 'pl-10 text-[14px]',
)}
>
<span className={cn('shrink-0 tabular-nums', f.level === 1 ? 'w-5 text-[13px] font-medium' : 'w-7 text-[12px]', on ? 'text-primary' : 'text-subtle-foreground')}>{f.number}</span>
<span className={cn('min-w-0 flex-1', on ? 'font-medium text-foreground' : read ? 'text-muted-foreground' : 'text-foreground/90')}>{f.title}</span>
{on ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_18%,transparent)]" />
) : read ? (
<Icon name="check" size={14} className="shrink-0 text-subtle-foreground" label={t.done} />
) : (
entry?.minutes && <span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{fill(t.minutes, { n: entry.minutes })}</span>
)}
</a>
</li>
);
})}
</ol>
</div>
</div>
</nav>
</Reveal>
<div ref={article} className="mt-14">
{p.children ?? <Article entries={p.entries} level={level as 2 | 3} />}
</div>
{/* the way back, once the box has scrolled away */}
<div className="pointer-events-none sticky bottom-4 z-20 mt-10 flex justify-end sm:bottom-6">
<a
href={`#${panel}-box`}
onClick={backToBox}
tabIndex={away ? 0 : -1}
aria-hidden={!away}
className={cn(
'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border border-border bg-background/90 px-4 text-[13px] font-medium text-foreground shadow-[var(--shadow-lg)] backdrop-blur-md transition-[opacity,transform,visibility] duration-300 hover:bg-accent outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring motion-reduce:transition-none',
away ? 'pointer-events-auto visible translate-y-0 opacity-100' : 'invisible translate-y-3 opacity-0',
)}
>
<Icon name="arrow-up" size={15} />
{t.back}
</a>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Minimap */
/* ------------------------------------------------------------------ */
function Minimap({ p, t }: { p: TableOfContentsProps; t: Texts }) {
const flat = flatten(p.entries);
const article = useRef<HTMLDivElement>(null);
const { active, progress } = useScrollSpy(
flat.map((f) => f.id),
article,
);
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
const jump = useJump(close);
const panel = useId();
const level = p.as === 'h1' ? 2 : 3;
const C = round(2 * Math.PI * 19);
const toggle = useRef<HTMLButtonElement>(null);
const pop = useRef<HTMLDivElement>(null);
// Escape closes and gives the focus back to the button, a press outside closes
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
setOpen(false);
toggle.current?.focus();
};
const onDown = (e: PointerEvent) => {
if (!pop.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onDown);
};
}, [open]);
const link = (f: Flat, extra?: string) => (
<a
href={`#${f.id}`}
onClick={(e) => jump(e, f.id)}
aria-current={active === f.id ? 'location' : undefined}
className={cn(
'flex min-h-10 items-center rounded-md px-3 text-[14px] leading-snug transition-colors outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring',
f.level === 2 && 'pl-7 text-[13.5px]',
active === f.id ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
extra,
)}
>
{f.title}
</a>
);
return (
<div className="relative">
<Header p={p} className="mx-auto max-w-[36rem] lg:mx-0 lg:ml-[calc(50%-18rem)]" />
<div className="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_36rem_minmax(0,1fr)]">
<div ref={article} className="min-w-0 lg:col-start-2">
{p.children ?? <Article entries={p.entries} level={level as 2 | 3} />}
</div>
{/* desktop: ticks at the edge, the titles open on hover or focus */}
<nav aria-label={t.contents} className="group/map sticky top-24 hidden self-start justify-self-end lg:col-start-3 lg:row-start-1 lg:block">
<div className="relative flex justify-end">
<ol className="flex flex-col items-end gap-2.5 py-3 pl-6 pr-1" aria-hidden>
{flat.map((f) => (
<li key={f.id} className={cn('h-[3px] rounded-full transition-[width,background-color] duration-300 ease-[var(--ease-kit)]', active === f.id ? 'w-7 bg-primary' : f.level === 1 ? 'w-5 bg-border-strong' : 'w-3 bg-border')} />
))}
</ol>
<div className="pointer-events-none absolute right-0 top-0 w-72 translate-x-2 opacity-0 transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] group-hover/map:pointer-events-auto group-hover/map:translate-x-0 group-hover/map:opacity-100 group-focus-within/map:pointer-events-auto group-focus-within/map:translate-x-0 group-focus-within/map:opacity-100 motion-reduce:transition-none">
<div className="kit-card p-2 shadow-[var(--highlight),var(--shadow-lg)]">
<p className="px-3 pb-1.5 pt-2 text-[12px] font-medium text-subtle-foreground">{t.onThisPage}</p>
<ol>
{flat.map((f) => (
<li key={f.id}>{link(f)}</li>
))}
</ol>
</div>
</div>
</div>
</nav>
</div>
{/* phones and tablets: a round button with the progress that opens the list */}
<div className="pointer-events-none sticky bottom-4 z-20 mt-10 flex justify-end lg:hidden">
<div ref={pop} className="pointer-events-auto relative">
<div id={panel} hidden={!open} className="absolute bottom-[calc(100%+0.75rem)] right-0 w-[min(20rem,calc(100vw-3rem))]">
<nav aria-label={t.contents} className="kit-card max-h-[60vh] overflow-y-auto p-2 shadow-[var(--highlight),var(--shadow-lg)]">
<p className="px-3 pb-1.5 pt-2 text-[12px] font-medium text-subtle-foreground">{t.onThisPage}</p>
<ol>
{flat.map((f) => (
<li key={f.id}>{link(f, 'min-h-11')}</li>
))}
</ol>
</nav>
</div>
<button
ref={toggle}
type="button"
aria-expanded={open}
aria-controls={panel}
aria-label={open ? t.close : t.open}
onClick={() => setOpen((o) => !o)}
className="relative flex size-12 cursor-pointer items-center justify-center rounded-full border border-border bg-background/90 text-foreground shadow-[var(--shadow-lg)] backdrop-blur-md transition-transform duration-200 active:scale-95 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring"
>
<svg aria-hidden viewBox="0 0 44 44" className="absolute inset-0.5 -rotate-90">
<circle cx="22" cy="22" r="19" fill="none" strokeWidth="2" className="stroke-track" />
<circle cx="22" cy="22" r="19" fill="none" strokeWidth="2" strokeLinecap="round" className="stroke-primary transition-[stroke-dashoffset] duration-300" strokeDasharray={C} strokeDashoffset={round(C * (1 - progress))} />
</svg>
<Icon name={open ? 'x' : 'list'} size={17} />
</button>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Chapters */
/* ------------------------------------------------------------------ */
function Chapters({ p, t }: { p: TableOfContentsProps; t: Texts }) {
const ids = p.entries.map((e) => e.id);
const article = useRef<HTMLDivElement>(null);
const { active, progress } = useScrollSpy(ids, article);
const jump = useJump();
const level = p.as === 'h1' ? 2 : 3;
const index = Math.max(0, ids.indexOf(active));
const started = progress > 0.01;
const readCount = progress >= 0.999 ? ids.length : started ? index : 0;
const next = p.entries[started ? index : 0];
return (
<>
<div className="mb-10 grid gap-6 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-16">
<Header p={p} className="mb-0" />
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 lg:justify-end">
<span className="text-[14px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.read, { a: readCount, b: ids.length })}
</span>
{next && (
<a
href={`#${next.id}`}
onClick={(e) => jump(e, next.id)}
className="group inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 text-[14px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring"
>
<span className="truncate">{started ? `${t.resume} ${next.title}` : t.start}</span>
<Icon name="arrow-down" size={16} className="shrink-0 transition-transform duration-300 group-hover:translate-y-0.5" />
</a>
)}
</div>
</div>
<nav aria-label={t.contents}>
<ol className="grid grid-cols-[minmax(0,1fr)] gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3">
{p.entries.map((e, i) => {
const on = started && e.id === active && progress < 0.999;
const read = i < readCount;
return (
<Reveal as="li" key={e.id} delay={i * 70} className="flex">
<a
href={`#${e.id}`}
onClick={(ev) => jump(ev, e.id)}
aria-current={on ? 'location' : undefined}
className={cn(
'group relative flex w-full flex-col overflow-hidden rounded-xl border bg-card px-5 py-4 sm:py-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring motion-reduce:hover:translate-y-0',
on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="text-[12px] font-medium tabular-nums text-subtle-foreground">{fill(t.chapter, { n: String(i + 1).padStart(2, '0') })}</span>
{read ? (
<span className="inline-flex items-center gap-1 text-[12px] font-medium text-success-foreground">
<Icon name="check" size={13} strokeWidth={2.2} />
{t.done}
</span>
) : on ? (
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary">
<span aria-hidden className="size-1.5 rounded-full bg-primary motion-safe:animate-kit-pulse" />
{t.current}
</span>
) : (
e.minutes && <span className="text-[12px] tabular-nums text-subtle-foreground">{fill(t.minutes, { n: e.minutes })}</span>
)}
</span>
<span className="mt-2 text-balance text-[16px] font-semibold leading-snug tracking-[-0.015em] text-foreground sm:mt-5 sm:text-[17px]">{e.title}</span>
{/* phones: title and status only, so six chapters stay a short list above the text */}
{e.summary && <span className="mt-2 hidden text-pretty text-[14px] leading-relaxed text-muted-foreground sm:block">{e.summary}</span>}
<span aria-hidden className="mt-auto pt-3 sm:pt-5">
<span className="block h-0.5 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', read ? 'scale-x-100' : on ? 'scale-x-50' : 'scale-x-0')} />
</span>
</span>
</a>
</Reveal>
);
})}
</ol>
</nav>
<div ref={article} className="mx-auto mt-20 max-w-[36rem]">
{p.children ?? <Article entries={p.entries} level={level as 2 | 3} />}
</div>
</>
);
}
/**
* A table of contents for long pages: a sticky sidebar rail, a foldable
* contents box, an edge minimap or chapter cards, each with scroll spy.
* <TableOfContents variant="sidebar" title="…" entries={[{ id: 'start', title: 'Start', body: ['…'] }]} />
*/
export function TableOfContents(props: TableOfContentsProps) {
const { variant = 'sidebar', className, labels } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant === 'inline-box' ? <InlineBox p={props} t={t} /> : variant === 'minimap' ? <Minimap p={props} t={t} /> : variant === 'chapters' ? <Chapters p={props} t={t} /> : <Sidebar p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Table of contents?
With aiw add @ai-website/table-of-contents. The command puts components/ui/table-of-contents.tsx into your project, the code is yours from then on. It brings along icon, section and reveal. It needs no further npm packages.
Which variants does Table of contents have?
4 variants, Sticky sidebar, Contents box, Edge minimap and Chapter cards. You pick one with the variant prop, for example variant="inline-box".
Can I translate the texts of Table of contents?
Yes. Table of contents carries 15 texts of its own in English and German, such as “Contents”, “On this page” and “Show contents”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Table of contents?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Table of contents with aiw_components and install it with aiw_add @ai-website/table-of-contents. Name the variant in your prompt, for example variant="sidebar", and the assistant writes your content straight into the props.