Table of Contents

On-this-page links that follow your scroll

Table of Contents lists the headings on the page and highlights the one you're reading. It reads headings from the DOM, so it works with MDX, CMS content or hand-written JSX without a build step. The "On this page" list beside this text is one.

Installation

Install the dependencies.

npm install @kushagradhawan/kookie-ui

Copy these files into components/blocks/table-of-contents. Keep the folder names, since blocks import each other by relative path.

components/blocks/table-of-contents/table-of-contents.tsx
'use client';

import * as React from 'react';
import { Flex, Text } from '@kushagradhawan/kookie-ui';
import './table-of-contents.css';

export interface TocItem {
  id: string;
  text: string;
  level: number;
}

export interface TableOfContentsProps {
  className?: string;
  /** Heading levels to list. @default [2] */
  levels?: number[];
  /** @default "On this page" */
  title?: string;
  /** Wrap the rendered list, e.g. in a card. Not called when there are no headings. */
  renderContainer?: (content: React.ReactNode) => React.ReactNode;
  /** Where to look for headings. @default "[data-content-area]" */
  contentSelector?: string;
}

const DEFAULT_LEVELS = [2];

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .trim();
}

/**
 * Lists the headings inside the content area and highlights the one in view. Headings without
 * an id get one from their text.
 */
export const TableOfContents = React.memo(function TableOfContents({
  className,
  levels = DEFAULT_LEVELS,
  title = 'On this page',
  renderContainer,
  contentSelector = '[data-content-area]',
}: TableOfContentsProps) {
  const [toc, setToc] = React.useState<TocItem[]>([]);
  const [activeId, setActiveId] = React.useState('');
  const levelsKey = levels.join(',');

  React.useEffect(() => {
    const contentArea = document.querySelector(contentSelector);
    if (!contentArea) return;

    const selector = levelsKey
      .split(',')
      .map((level) => `h${level}`)
      .join(', ');
    let intersectionObserver: IntersectionObserver | null = null;
    let frame = 0;
    let signature: string | null = null;

    const scan = () => {
      const headings = Array.from(contentArea.querySelectorAll<HTMLElement>(selector))
        .map((heading) => {
          const text = heading.textContent ?? '';
          if (!heading.id && text) heading.id = slugify(text);
          return { id: heading.id, text, level: Number(heading.tagName.charAt(1)) };
        })
        .filter((item) => item.id && item.text);

      // Live previews mutate the DOM constantly; only act when the headings themselves change.
      const nextSignature = headings.map((heading) => `${heading.level}:${heading.id}:${heading.text}`).join('|');
      if (nextSignature === signature) return;
      signature = nextSignature;

      setToc(headings);
      intersectionObserver?.disconnect();
      intersectionObserver = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) setActiveId(entry.target.id);
          }
        },
        { rootMargin: '-20% 0% -35% 0%' },
      );
      for (const heading of headings) {
        const element = document.getElementById(heading.id);
        if (element) intersectionObserver.observe(element);
      }
    };

    // Rescan when the content changes, e.g. switching tabs. One scan per frame at most.
    const scheduleScan = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(scan);
    };
    const mutationObserver = new MutationObserver(scheduleScan);
    mutationObserver.observe(contentArea, { childList: true, subtree: true });
    scheduleScan();

    return () => {
      cancelAnimationFrame(frame);
      mutationObserver.disconnect();
      intersectionObserver?.disconnect();
    };
  }, [levelsKey, contentSelector]);

  if (toc.length === 0) return null;

  const content = (
    <Flex direction="column" gap="3" className={className}>
      <Text size="2" emphasis="medium">
        {title}
      </Text>
      <Text asChild size="2">
        <nav aria-label={title} className="kd-toc-list">
          {toc.map((item) => (
            <a
              key={item.id}
              href={`#${item.id}`}
              className="kd-toc-link"
              data-level={item.level}
              aria-current={activeId === item.id ? 'location' : undefined}
            >
              {item.text}
            </a>
          ))}
        </nav>
      </Text>
    </Flex>
  );

  return renderContainer ? renderContainer(content) : content;
});
components/blocks/table-of-contents/table-of-contents.css
/* A rail with the current section lit, as in v2's table of contents. Each entry draws its own
   segment of the rail, so there is no gap between entries: the space is the link's padding. */
.kd-toc-list {
  display: flex;
  flex-direction: column;
}

.kd-toc-link {
  display: block;
  padding-block: var(--space-2);
  padding-inline-start: var(--space-4);
  border-inline-start: 1px solid var(--gray-a5);
  color: var(--gray-11);
  text-decoration: none;
  transition: color 120ms, border-color 120ms;
}

.kd-toc-link[data-level='3'] {
  padding-inline-start: var(--space-6);
}

.kd-toc-link[aria-current] {
  color: var(--gray-12);
  border-inline-start-color: var(--accent-9);
}

@media (hover: hover) {
  .kd-toc-link:hover {
    color: var(--gray-12);
  }
}

.kd-toc-link:focus-visible {
  outline: 2px solid var(--focus-8);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .kd-toc-link {
    transition: none;
  }
}

Usage

Mark the element that holds your content with data-content-area, then render the list anywhere.

import { TableOfContents } from '@/components/blocks/table-of-contents/table-of-contents';
 
export function Article({ children }: { children: React.ReactNode }) {
  return (
    <Flex gap="6">
      <Box data-content-area>{children}</Box>
      <Box width="240px" position="sticky" top="8">
        <TableOfContents />
      </Box>
    </Flex>
  );
}

Docs Page sets up both for you.

Props

PropTypeDefaultDescription
levelsnumber[][2]Heading levels to list. Deeper levels are indented.
titlestring'On this page'Label above the list.
contentSelectorstring'[data-content-area]'Where to look for headings.
renderContainer(content: ReactNode) => ReactNode-Wrap the list, e.g. in a card. Not called when there are no headings.
classNamestring-Class on the list.

Behavior

  • Headings without an id get one from their text, so links work without a slug plugin.
  • The list rescans when the content changes, such as when a page switches tabs. Changes that don't touch the headings are ignored.
  • The active heading is the one in the band between 20% from the top and 35% from the bottom of the viewport.
  • It renders nothing when there are no matching headings.

Examples

Two Levels

<TableOfContents levels={[2, 3]} />

In a Card

<TableOfContents
  renderContainer={(content) => (
    <Card variant="classic" size="2">
      {content}
    </Card>
  )}
/>

Accessibility

Entries are ordinary in-page links and work with the keyboard. Wrap the list in a nav with an aria-label, such as "On this page", if your layout has other navigation landmarks.

© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background