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
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • 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
  • Active section tracking for the current heading while you scroll
  • 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 {
  type EditorElementProps,
  NavigationFeedbackPlugin,
  EditorElement,
  useEditor,
  useEditorRootElement,
  useEditorScrollElement,
  useEditorSelector,
} 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 underline decoration-[0.5px] underline-offset-4',
  {
    variants: {
      active: {
        false: 'text-muted-foreground hover:bg-accent hover:text-foreground',
        true: 'bg-accent text-foreground decoration-foreground',
      },
      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 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'),
    }
  );
  const container = useEditorScrollElement(editor);
  const isScrollable =
    (container?.scrollHeight || 0) > (container?.clientHeight || 0);
  const root = useEditorRootElement(editor);
  const ownerWindow = root?.ownerDocument.defaultView;
  const scrollContainer = isScrollable ? container : ownerWindow;
  const [activeKey, setActiveKey] = React.useState<NodeKey | null>(null);
 
  // oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Disconnect releases both initial and refreshed observer bindings.
  React.useEffect(() => {
    if (!ownerWindow) return undefined;
 
    let active = true;
    const entries = new Map<NodeKey, IntersectionObserverEntry>();
    const observed = new Map<NodeKey, Element>();
    const keys = new WeakMap<Element, NodeKey>();
    const observer = new ownerWindow.IntersectionObserver(
      (headings) => {
        if (!active) return;
        headings.forEach((heading) => {
          const key = keys.get(heading.target);
          if (key && observed.get(key) === heading.target) {
            entries.set(key, heading);
          }
        });
        const firstVisible = headingList.find(
          ({ key }) => entries.get(key)?.isIntersecting
        );
        if (firstVisible) setActiveKey(firstVisible.key);
      },
      { root: isScrollable ? container : null }
    );
    const refresh = () => {
      headingList.forEach(({ key }) => {
        const node = editor.read.nodes.get(key)?.[0];
        const element = node ? editor.api.dom.resolveDOMNode(node) : null;
        const previous = observed.get(key);
        if (previous === element) return;
        if (previous) {
          observer.unobserve(previous);
          observed.delete(key);
          entries.delete(key);
        }
        if (element) {
          observed.set(key, element);
          keys.set(element, key);
          observer.observe(element);
        }
      });
    };
    refresh();
    scrollContainer?.addEventListener('scroll', refresh, { passive: true });
    return () => {
      active = false;
      scrollContainer?.removeEventListener('scroll', refresh);
      observer.disconnect();
    };
  }, [
    container,
    editor,
    headingList,
    isScrollable,
    ownerWindow,
    scrollContainer,
  ]);
 
  return (
    <EditorElement {...props} className="mb-1 p-0">
      <div contentEditable={false}>
        {headingList.length > 0 ? (
          headingList.map((item) => (
            <Button
              key={item.key}
              variant="ghost"
              className={headingItemVariants({
                active: item.key === activeKey,
                depth: item.depth as 1 | 2 | 3,
              })}
              onClick={(event) => {
                event.preventDefault();
 
                const node = editor.read.nodes.get(item.key)?.[0];
 
                if (!node) return;
 
                const element = editor.api.dom.resolveDOMNode(node);
 
                if (!element) return;
 
                setActiveKey(item.key);
 
                const path = editor.read.nodes.path(item.key);
 
                if (path) {
                  element.style.scrollMarginTop = `${topOffset}px`;
 
                  if (isScroll) {
                    editor.api.dom.scrollIntoView(path, {
                      behavior: 'smooth',
                      block: 'start',
                      scrollMode: 'always',
                    });
                  }
                  navigation.api.flashTarget({
                    key: item.key,
                    attributes: {
                      className: 'rounded-md bg-(--color-highlight)',
                    },
                  });
                }
              }}
              aria-current={item.key === activeKey ? 'location' : undefined}
            >
              {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 {
  type EditorElementProps,
  NavigationFeedbackPlugin,
  EditorElement,
  useEditor,
  useEditorRootElement,
  useEditorScrollElement,
  useEditorSelector,
} from 'platejs/react';
import { TocPlugin } from 'platejs/toc/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
 
const headingItemVariants = cva
















































































































































































  • 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 @tanstack/react-virtual
pnpm add platejs @tanstack/react-virtual

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 active-heading tracking, 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().

(
'block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium underline decoration-[0.5px] underline-offset-4',
{
variants: {
active: {
false: 'text-muted-foreground hover:bg-accent hover:text-foreground',
true: 'bg-accent text-foreground decoration-foreground',
},
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 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'),
}
);
const container = useEditorScrollElement(editor);
const isScrollable =
(container?.scrollHeight || 0) > (container?.clientHeight || 0);
const root = useEditorRootElement(editor);
const ownerWindow = root?.ownerDocument.defaultView;
const scrollContainer = isScrollable ? container : ownerWindow;
const [activeKey, setActiveKey] = React.useState<NodeKey | null>(null);
// oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Disconnect releases both initial and refreshed observer bindings.
React.useEffect(() => {
if (!ownerWindow) return undefined;
let active = true;
const entries = new Map<NodeKey, IntersectionObserverEntry>();
const observed = new Map<NodeKey, Element>();
const keys = new WeakMap<Element, NodeKey>();
const observer = new ownerWindow.IntersectionObserver(
(headings) => {
if (!active) return;
headings.forEach((heading) => {
const key = keys.get(heading.target);
if (key && observed.get(key) === heading.target) {
entries.set(key, heading);
}
});
const firstVisible = headingList.find(
({ key }) => entries.get(key)?.isIntersecting
);
if (firstVisible) setActiveKey(firstVisible.key);
},
{ root: isScrollable ? container : null }
);
const refresh = () => {
headingList.forEach(({ key }) => {
const node = editor.read.nodes.get(key)?.[0];
const element = node ? editor.api.dom.resolveDOMNode(node) : null;
const previous = observed.get(key);
if (previous === element) return;
if (previous) {
observer.unobserve(previous);
observed.delete(key);
entries.delete(key);
}
if (element) {
observed.set(key, element);
keys.set(element, key);
observer.observe(element);
}
});
};
refresh();
scrollContainer?.addEventListener('scroll', refresh, { passive: true });
return () => {
active = false;
scrollContainer?.removeEventListener('scroll', refresh);
observer.disconnect();
};
}, [
container,
editor,
headingList,
isScrollable,
ownerWindow,
scrollContainer,
]);
return (
<EditorElement {...props} className="mb-1 p-0">
<div contentEditable={false}>
{headingList.length > 0 ? (
headingList.map((item) => (
<Button
key={item.key}
variant="ghost"
className={headingItemVariants({
active: item.key === activeKey,
depth: item.depth as 1 | 2 | 3,
})}
onClick={(event) => {
event.preventDefault();
const node = editor.read.nodes.get(item.key)?.[0];
if (!node) return;
const element = editor.api.dom.resolveDOMNode(node);
if (!element) return;
setActiveKey(item.key);
const path = editor.read.nodes.path(item.key);
if (path) {
element.style.scrollMarginTop = `${topOffset}px`;
if (isScroll) {
editor.api.dom.scrollIntoView(path, {
behavior: 'smooth',
block: 'start',
scrollMode: 'always',
});
}
navigation.api.flashTarget({
key: item.key,
attributes: {
className: 'rounded-md bg-(--color-highlight)',
},
});
}
}}
aria-current={item.key === activeKey ? 'location' : undefined}
>
{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>
);
}