Zum Inhalt springen
UI Kit
Accent

Inhaltsverzeichnis mit Scrollspy

Inhaltsverzeichnisse mit Scrollspy für Ratgeber und Artikel. Eine klebende Leiste, ein Ausklappkasten, eine Randkarte und Kapitelkarten.

4 VariantsEine von 5 Components in Artikel-Bausteine

Immer wissen, wo du im Text bist

Ein Inhaltsverzeichnis sagt Lesern, was ein Artikel abdeckt und wo sie gerade sind. Alle vier Fassungen folgen dem Scroll und markieren den aktuellen Abschnitt, Links springen zur Überschrift, und jede hat ein eigenes Layout fürs Handy.

Die „Klebende Seitenleiste“ läuft neben dem Text mit gleitender Markierung und füllender Linie, am Handy wird sie zur ausklappbaren Leiste. Der „Inhaltskasten“ steht nummeriert unter dem Titel, klappt ein, markiert Gelesenes und bietet einen Weg zurück. Die „Randkarte“ zeigt leise Striche am Rand, die sich bei Hover oder Fokus zu Titeln öffnen, am Handy mit Fortschrittsknopf. „Kapitelkarten“ zeigen Kapitel mit Zusammenfassung und Lesezeit, markieren sie beim Scrollen als gelesen und bieten einen Link zum Weiterlesen.

Nimm die Seitenleiste für Ratgeber ab 1500 Wörtern, den Inhaltskasten für mittellange Artikel. Die Randkarte passt zu Texten, die eine ungestörte Lesespalte wollen. Kapitelkarten passen zu Kursen und mehrteiligen Ratgebern, zu denen Leser zurückkehren.

Variants

Klebende SeitenleisteNeu in 1.2

Eine Leiste neben dem Text mit gleitender Marke und füllender Linie, auf dem Telefon eine Leiste zum Ausklappen.

InhaltskastenNeu in 1.2

Ein nummerierter Kasten unter dem Titel, der sich einklappt, Gelesenes markiert und den Weg zurück anbietet.

RandkarteNeu in 1.2

Ruhige Striche am Rand, die sich bei Zeiger oder Fokus zu Titeln öffnen, auf dem Telefon ein Knopf mit Fortschritt.

KapitelkartenNeu in 1.2

Kapitel als Karten mit Kurztext und Minuten, beim Scrollen als gelesen markiert, mit Link zum Weiterlesen.

Details

Was Inhaltsverzeichnis mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/table-of-contents.tsx
Bringt mit
utilskiticonsectionreveal
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 15 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Blog oder Magazin

Props

Die Schnittstelle TableOfContentsProps aus components/ui/table-of-contents.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    TableOfContentsVariant

    sidebarinline-boxminimapchapters

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • entriesPflicht
    TocEntry[]
  • childrenoptional
    React.ReactNode

    Your 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.

  • defaultCollapsedoptional
    boolean

    inline-box: the box starts folded

Eingebaute Texte

Diese Texte schreibt Inhaltsverzeichnis selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 15 Texte anzeigen
contentsEnglisch ContentsDeutsch Inhalt
onThisPageEnglisch On this pageDeutsch Auf dieser Seite
showEnglisch Show contentsDeutsch Inhalt zeigen
hideEnglisch Hide contentsDeutsch Inhalt ausblenden
backEnglisch Back to contentsDeutsch Zurück zum Inhalt
minutesEnglisch {n} minDeutsch {n} Min.
readEnglisch {a} of {b} readDeutsch {a} von {b} gelesen
progressEnglisch Read so farDeutsch Bisher gelesen
resumeEnglisch Continue withDeutsch Weiter mit
startEnglisch Start readingDeutsch Mit dem Lesen beginnen
currentEnglisch You are hereDeutsch Du bist hier
doneEnglisch ReadDeutsch Gelesen
chapterEnglisch Chapter {n}Deutsch Kapitel {n}
openEnglisch Open contentsDeutsch Inhalt öffnen
closeEnglisch Close contentsDeutsch Inhalt schließen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Inhaltsverzeichnis reagiert selbst auf die Tasten Escape.
  • Setzt aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 713 Zeilen
components/ui/table-of-contents.tsx
'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>
  );
}

Häufige Fragen

Wie installiere ich Inhaltsverzeichnis?

Mit aiw add @ai-website/table-of-contents. Der Befehl legt die Datei components/ui/table-of-contents.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, section und reveal. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Inhaltsverzeichnis?

4 Varianten, Klebende Seitenleiste, Inhaltskasten, Randkarte und Kapitelkarten. Du wählst sie mit der Prop variant, zum Beispiel variant="inline-box".

Kann ich die Texte von Inhaltsverzeichnis übersetzen?

Ja. Inhaltsverzeichnis bringt 15 eigene Texte auf Deutsch und Englisch mit, etwa „Inhalt“, „Auf dieser Seite“ und „Inhalt zeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Inhaltsverzeichnis einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Inhaltsverzeichnis mit aiw_components und installieren sie mit aiw_add @ai-website/table-of-contents. Nenn im Prompt die Variante, etwa variant="sidebar", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert