---
title: Table of contents
description: The table of contents plugin adds Notion's table of contents block, the document's headings listed live, each scrolling to its heading.
icon: list-tree
---

`createTocPlugin()` adds Notion's table of contents: a `toc` block lists the document's headings and toggle headings, live, in reading order, each indented by its level. A click scrolls to the heading. It is not a default plugin: list it.

```svelte title="Editor.svelte" check
<script lang="ts">
	import { Edytor, createTocPlugin } from 'edytor';
</script>

<Edytor plugins={[createTocPlugin()]} />
```

A [slash menu](/docs/plugins/slash-menu) then lists "Table of contents" under `Advanced blocks` (keywords `toc`, `outline`, `headings`, `contents`). Like a divider it replaces an empty line, and goes after a line that holds text.

## What it lists

- Every `heading` and `toggle-heading` of the document, nested ones included (in a toggle, a column, a list item), in reading order. Its list is drawn from the view, never stored: an edit anywhere (a heading typed, added, removed, turned into text, a peer's change) updates it at once.
- Each entry is the heading's text (an inline atom, such as a mention, is left out), "Untitled" for an empty heading, and is indented one nesting step per level below the shallowest level listed: a document whose headings start at Heading 2 lists them flush.
- With no heading, it shows Notion's hint, "Add headings to create a table of contents."

A click scrolls the heading to the top of the view, smoothly, and opens the closed toggles it sits in. The caret and the selection stay where they were. A readonly view lists and scrolls too.

## Options

`createTocPlugin(options)` takes a `TocPluginOptions`:

| Prop | Type | Default | Description |
| - | - | - | - |
| `headingLevel?` | `(block: { type: string; data: Record<string, unknown> }) => number \| null` | - | Which blocks the table lists, and how deep (1 the outermost), else `null`. By default the rich text headings and toggle headings by their `data.level`: `h1` 1, `h2` 2, any other 3, as they are drawn. |
| `entry?` | `Snippet<[TocEntryPayload]>` | - | One entry's markup, replacing the default button (see [Your entries](#your-entries)). |
| `empty?` | `Snippet<[TocEmptyPayload]>` | - | What shows while the document has no heading, replacing the default paragraph: `{ label }`, the view's `toc.empty`. |

The first table of contents plugin listed in a view gives all three.

```ts title="toc.ts" check
import { createTocPlugin } from 'edytor';

// Your own heading kind beside the rich text ones.
export const toc = createTocPlugin({
	headingLevel: ({ type, data }) =>
		type === 'chapter' ? 1 : type === 'heading' ? (data.level === 'h1' ? 1 : 2) : null
});
```

### Your entries

`entry` is drawn once per heading, in reading order, inside the block's void wrapper (`[data-edytor-toc]`). It receives a `TocEntryPayload`: the heading's block `id`, its `level`, its `depth` (its level less the shallowest shown: how many steps in it sits), its `text` (`''` when it has none), its `label` (the text, else the view's `toc.untitled`), `go()` (scroll to it, opening the closed toggles it sits in) and `control`. Give a `button` `{@attach heading.control}`, as a menu's row takes its attachment: its marker (`data-edytor-toc-entry`), a press that keeps the caret and a click that runs `go()`. The indent is yours, from `depth` (the default markup sets `--edytor-toc-depth`, which the theme reads).

```svelte title="Contents.svelte" check
<script lang="ts">
	import {
		Edytor,
		createTocPlugin,
		type TocEmptyPayload,
		type TocEntryPayload
	} from 'edytor';

	const toc = createTocPlugin({ entry, empty });
</script>

{#snippet entry(heading: TocEntryPayload)}
	<button type="button" class="entry" style:padding-inline-start="{heading.depth}em" {@attach heading.control}>
		{'·'.repeat(heading.depth)} {heading.label}
	</button>
{/snippet}

{#snippet empty(none: TocEmptyPayload)}
	<p class="entry-none">{none.label}</p>
{/snippet}

<Edytor plugins={[toc]} />
```

## The kind

| Kind  | Element                                  | Data | Preset                                                |
| ----- | ---------------------------------------- | ---- | ----------------------------------------------------- |
| `toc` | `nav` with `aria-label="Table of contents"` | none | Table of contents (☰, group `Advanced blocks`) |

The block is void and shows no text of its own, like a divider. The command id is `block.toc`. Its role is in `defaultSemantics` (`tocKinds`). Its entries are buttons: they keep their events from the editor, so a click never moves the caret.

## Clipboard

Copying exports an empty `<nav data-edytor-toc></nav>` (its list is drawn, never carried) and no plain text; pasting that HTML back imports a table of contents.

## Styling

| Selector                       | Matches                                                                   |
| ------------------------------ | ------------------------------------------------------------------------- |
| `[data-edytor-type='toc']`     | The `nav`                                                                 |
| `[data-edytor-toc-entry]`      | One entry: `data-level` is its heading's level, `--edytor-toc-depth` its steps in, `[data-untitled]` for an empty heading |
| `[data-edytor-toc-empty]`      | The hint shown with no heading                                            |

Without a theme the entries are the browser's buttons, one after the other. The [Notion theme](/docs/customization/styling#notion-theme) draws them as Notion's lines, indented by `--edytor-toc-depth` times `--edytor-nest-indent`, in its secondary text colour, with a hover background.
