Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Table of Contents

PreviousNext

Renders a table of contents element with clickable links to headings in the document.

PlusToc Element
Loading…
TableFootnote

On This Page

FeaturesKit usageInstallationAdd kitManual usageInstallationAdd pluginsConfigure pluginInsert toolbar buttonScroll container setupPlate PlusPluginsTocPluginTocElementTransformseditor.plugin(BaseTocPlugin).update.insertRegistry UI
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Automatically generates a table of contents from document headings
  • Smooth scrolling to headings
  • Keyboard-accessible heading navigation with Enter and Space

TOC tracks headings with editor-scoped NodeKey values. It does not require persisted element IDs or install ElementIdPlugin.

Report an issue

Kit usage

Installation

The fastest way to add table of contents functionality is with the TocKit, which includes pre-configured TocPlugin with the Plate UI component.

'use client';
 
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
import {
  type EditorElementProps,
  NavigationFeedbackPlugin,
  EditorElement,
  useEditor,
  useEditorSelector,
  useElementSelected,
} from 'platejs/react';
import { TocPlugin } from 'platejs/toc/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
 
const headingItemVariants = cva(
  'block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium text-muted-foreground underline decoration-[0.5px] underline-offset-4 hover:bg-accent hover:text-foreground',
  {
    variants: {
      depth: {
        1: 'pl-0.5',
        2: 'pl-[26px]',
        3: 'pl-[50px]',
      },
    },
  }
);
 
export function TocElement({
  isScroll = true,
  topOffset = 80,
  ...props
}: EditorElementProps<typeof TocPlugin> & {
  isScroll?: boolean;
  topOffset?: number;
}) {
  const editor = useEditor();
  const navigation = useEditor().plugin(NavigationFeedbackPlugin);
  const details = editor.plugin(BaseDetailsPlugin);
  const openDetailsHolding = (key: NodeKey) => {
    const path = (details.installed && editor.read.nodes.path(key)) || [];
    const closedKeys = path.flatMap((childIndex, depth) => {
      const ancestor = path.slice(0, depth);
      const isTitle = childIndex === 0;
      const detailsKey =
        depth > 0 &&
        !isTitle &&
        editor.read.nodes.get(ancestor, { type: BaseDetailsPlugin })
          ? editor.key(ancestor)
          : undefined;
 
      return detailsKey && !details.store.get('openKeys').has(detailsKey)
        ? [detailsKey]
        : [];
    });
 
    for (const detailsKey of closedKeys) {
      details.api.setOpen(detailsKey, true);
    }
 
    return closedKeys.length > 0;
  };
  const selected = useElementSelected({ mode: 'text' });
  const headingList = useEditorSelector(
    (innerEditor) => innerEditor.plugin(TocPlugin).read.headings(),
    {
      equalityFn: (previous, next) =>
        previous !== null &&
        previous.length === next.length &&
        previous.every((heading, index) => {
          const nextHeading = next[index];
 
          return (
            heading.key === nextHeading?.key &&
            heading.depth === nextHeading.depth &&
            heading.title === nextHeading.title &&
            heading.type === nextHeading.type
          );
        }),
      shouldUpdate: (change) => !change || change.changed.hasAny('document'),
    }
  );
 
  return (
    <EditorElement {...props} className="mb-1 p-0">
      <div
        className="rounded-sm data-[selected=true]:bg-primary/10"
        contentEditable={false}
        data-selected={selected}
      >
        {headingList.length > 0 ? (
          headingList.map((item) => (
            <Button
              key={item.key}
              variant="ghost"
              className={headingItemVariants({
                depth: item.depth as 1 | 2 | 3,
              })}
              onClick={(event) => {
                event.preventDefault();
 
                const navigate = () => {
                  const entry = editor.read.nodes.get(item.key);
 
                  if (!entry) return;
 
                  const element = editor.api.dom.resolveDOMNode(entry[0]);
 
                  if (!element) return;
 
                  element.style.scrollMarginTop = `${topOffset}px`;
 
                  if (isScroll) {
                    editor.api.dom.scrollIntoView(entry[1], {
                      behavior: 'smooth',
                      block: 'start',
                      scrollMode: 'always',
                    });
                  }
                  navigation.api.flashTarget({
                    key: item.key,
                    attributes: {
                      className: 'rounded-md bg-(--color-highlight)',
                    },
                  });
                };
 
                if (openDetailsHolding(item.key)) {
                  requestAnimationFrame(navigate);
                } else {
                  navigate();
                }
              }}
            >
              {item.title}
            </Button>
          ))
        ) : (
          <div className="text-sm text-gray-500">
            Create a heading to display the table of contents.
          </div>
        )}
      </div>
      {props.children}
    </EditorElement>
  );
}
 
export const TocKit = [
  TocPlugin.configure({
    component: TocElement,
  }),
];
'use client';
 
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
import {
  type EditorElementProps,
  NavigationFeedbackPlugin,
  EditorElement,
  useEditor,
  useEditorSelector,
  useElementSelected,
} from 'platejs/react';
import { TocPlugin } from 'platejs/toc/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
 









































































































































  • TocElement: Renders table of contents elements.

Add kit

Add the kit to your plugins:

import { createEditor } from 'platejs/react';
import { TocKit } from '@/components/editor/toc';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    ...TocKit,
  ],
});
import { createEditor } from 'platejs/react';
import { TocKit } from '@/components/editor/toc';
 
const editor = createEditor({




Manual usage

Installation

pnpm add platejs
pnpm add platejs

Add plugins

Include TocPlugin and HeadingPlugin in your Plate plugins array when creating the editor.

import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    HeadingPlugin,
    TocPlugin,
  ],
});
import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } 








Configure plugin

Configure the TocPlugin with custom component and scroll options.

import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement } from '@/components/editor/heading';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    HeadingPlugin.configure({ component: HeadingElement }),
    TocPlugin.configure({
      component: (props) => <TocElement {...props} topOffset={80} isScroll


  • .configure({ component }): Assigns TocElement to render table of contents elements.
  • TocElement.topOffset: Sets the top offset when scrolling to headings.
  • TocElement.isScroll: Enables scrolling behavior to headings.

Insert toolbar button

You can add this item to the Insert Toolbar Button to insert table of contents elements:

{
  icon: <TableOfContentsIcon />,
  label: 'Table of contents',
  value: PLUGINS.toc,
}
{
  icon: <TableOfContentsIcon />,
  label: 'Table of contents',
  value: PLUGINS.toc,
}

Scroll container setup

Use EditorContainer for the editor's scrolling region. For a custom layout, render EditorContainer inside <EditorRoot> and apply your scrolling styles to it. EditorContainer registers that region with the editor.

import { EditorContainer, EditorContent } from 'platejs/react';
 
function Layout() {
  return (
    <EditorContainer className="h-[600px] overflow-y-auto">
      <EditorContent />
    </EditorContainer>
  );
}
import { EditorContainer, EditorContent } from 'platejs/react';
 
function Layout() {
  return (




Plate Plus

  • Sticky TOC sidebar
  • Hover-to-expand: Opens automatically when you move your mouse over it
  • Interactive navigation: Click on items to smoothly scroll to the corresponding heading
  • Visual feedback: Highlights the current section in the sidebar
  • Beautifully crafted UI
Get the code

Plugins

TocPlugin

Plugin for generating table of contents.

Options

    Custom function to query headings. Defaults to null, which uses the built-in query.

TocElement

Scroll options belong to the copied view. The heading query belongs to TocPlugin.

Options

    Enable scrolling behavior.

    • Default: true

    Top offset when scrolling to heading.

    • Default: 80

Transforms

editor.plugin(BaseTocPlugin).update.insert

Insert table of contents element.

import { BaseTocPlugin } from 'platejs/toc';
 
editor.plugin(BaseTocPlugin).update.insert();
import { BaseTocPlugin } from 'platejs/toc';
 
editor.plugin(BaseTocPlugin).update.insert();

Parameters

    Initial table-of-contents element properties.

    Standard node insertion options such as at and select.

Registry UI

The copied toc component owns scrolling and navigation feedback. Keep those product-facing interaction choices beside the renderer; the package plugin owns heading discovery through editor.plugin(TocPlugin).read.headings().

const
headingItemVariants
=
cva
(
'block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium text-muted-foreground underline decoration-[0.5px] underline-offset-4 hover:bg-accent hover:text-foreground',
{
variants: {
depth: {
1: 'pl-0.5',
2: 'pl-[26px]',
3: 'pl-[50px]',
},
},
}
);
export function TocElement({
isScroll = true,
topOffset = 80,
...props
}: EditorElementProps<typeof TocPlugin> & {
isScroll?: boolean;
topOffset?: number;
}) {
const editor = useEditor();
const navigation = useEditor().plugin(NavigationFeedbackPlugin);
const details = editor.plugin(BaseDetailsPlugin);
const openDetailsHolding = (key: NodeKey) => {
const path = (details.installed && editor.read.nodes.path(key)) || [];
const closedKeys = path.flatMap((childIndex, depth) => {
const ancestor = path.slice(0, depth);
const isTitle = childIndex === 0;
const detailsKey =
depth > 0 &&
!isTitle &&
editor.read.nodes.get(ancestor, { type: BaseDetailsPlugin })
? editor.key(ancestor)
: undefined;
return detailsKey && !details.store.get('openKeys').has(detailsKey)
? [detailsKey]
: [];
});
for (const detailsKey of closedKeys) {
details.api.setOpen(detailsKey, true);
}
return closedKeys.length > 0;
};
const selected = useElementSelected({ mode: 'text' });
const headingList = useEditorSelector(
(innerEditor) => innerEditor.plugin(TocPlugin).read.headings(),
{
equalityFn: (previous, next) =>
previous !== null &&
previous.length === next.length &&
previous.every((heading, index) => {
const nextHeading = next[index];
return (
heading.key === nextHeading?.key &&
heading.depth === nextHeading.depth &&
heading.title === nextHeading.title &&
heading.type === nextHeading.type
);
}),
shouldUpdate: (change) => !change || change.changed.hasAny('document'),
}
);
return (
<EditorElement {...props} className="mb-1 p-0">
<div
className="rounded-sm data-[selected=true]:bg-primary/10"
contentEditable={false}
data-selected={selected}
>
{headingList.length > 0 ? (
headingList.map((item) => (
<Button
key={item.key}
variant="ghost"
className={headingItemVariants({
depth: item.depth as 1 | 2 | 3,
})}
onClick={(event) => {
event.preventDefault();
const navigate = () => {
const entry = editor.read.nodes.get(item.key);
if (!entry) return;
const element = editor.api.dom.resolveDOMNode(entry[0]);
if (!element) return;
element.style.scrollMarginTop = `${topOffset}px`;
if (isScroll) {
editor.api.dom.scrollIntoView(entry[1], {
behavior: 'smooth',
block: 'start',
scrollMode: 'always',
});
}
navigation.api.flashTarget({
key: item.key,
attributes: {
className: 'rounded-md bg-(--color-highlight)',
},
});
};
if (openDetailsHolding(item.key)) {
requestAnimationFrame(navigate);
} else {
navigate();
}
}}
>
{item.title}
</Button>
))
) : (
<div className="text-sm text-gray-500">
Create a heading to display the table of contents.
</div>
)}
</div>
{props.children}
</EditorElement>
);
}
export const TocKit = [
TocPlugin.configure({
component: TocElement,
}),
];
plugins: [
// ...otherPlugins,
...TocKit,
],
});
from
'platejs/react'
;
import { createEditor } from 'platejs/react';
const editor = createEditor({
plugins: [
// ...otherPlugins,
HeadingPlugin,
TocPlugin,
],
});
/>,
}),
],
});
import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement } from '@/components/editor/heading';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    HeadingPlugin.configure({ component: HeadingElement }),
    TocPlugin.configure({
      component: (props) => <TocElement {...props} topOffset={80} isScroll />,
    }),
  ],
});
<
EditorContainer
className
=
"h-[600px] overflow-y-auto"
>
<EditorContent />
</EditorContainer>
);
}