From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • 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
    • Cursor Overlay
    • 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 PlusPluginsTocPluginTransformseditor.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 { TocPlugin } from '@platejs/toc/react';
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import {
  type PlateElementProps,
  NavigationFeedbackPlugin,
  PlateElement,
  useEditor,
  useEditorPlugin,
  useEditorScrollElement,
  useEditorSelector,
  usePluginStore,
} from 'platejs/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(props: PlateElementProps<typeof TocPlugin>) {
  const headingElementsRef = React.useRef<
    Record<string, IntersectionObserverEntry>
  >({});
  const headingKeysRef = React.useRef(new WeakMap<Element, NodeKey>());
  const editor = useEditor();
  const navigation = useEditorPlugin(NavigationFeedbackPlugin);
  const isScroll = usePluginStore(TocPlugin, 'isScroll');
  const topOffset = usePluginStore(TocPlugin, 'topOffset');
  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 scrollContainer =
    typeof window === 'object'
      ? isScrollable
        ? container
        : window
      : undefined;
  const [status, setStatus] = React.useState(0);
  const [activeKey, setActiveKey] = React.useState<NodeKey | null>(null);
  const [selectedContent, setSelectedContent] = React.useState<{
    key: NodeKey;
    observedKey: NodeKey | null;
  }>();
  const activeContentKey =
    selectedContent?.observedKey === activeKey
      ? selectedContent.key
      : activeKey;
 
  React.useEffect(() => {
    const observer = new IntersectionObserver(
      (headings) => {
        headingElementsRef.current = headings.reduce((map, heading) => {
          const key = headingKeysRef.current.get(heading.target);
 
          if (key) map[key] = heading;
 
          return map;
        }, headingElementsRef.current);
 
        const firstVisible = Object.keys(headingElementsRef.current).find(
          (key) => headingElementsRef.current[key].isIntersecting
        );
 
        if (firstVisible) setActiveKey(firstVisible as NodeKey);
        headingElementsRef.current = {};
      },
      {
        root: isScrollable ? container : undefined,
        rootMargin: '0px 0px 0px 0px',
      }
    );
 
    headingList.forEach(({ key }) => {
      const node = editor.read.nodes.get(key)?.[0];
 
      if (!node) return;
 
      const element = editor.api.dom.resolveDOMNode(node);
 
      if (element) {
        headingKeysRef.current.set(element, key);
        observer.observe(element);
      }
    });
 
    return () => {
      observer.disconnect();
    };
  }, [container, editor, headingList, isScrollable, status]);
 
  React.useEffect(() => {
    if (!scrollContainer) return undefined;
 
    const scroll = () => {
      setStatus(Date.now());
    };
 
    scrollContainer.addEventListener('scroll', scroll);
 
    return () => {
      scrollContainer.removeEventListener('scroll', scroll);
    };
  }, [scrollContainer]);
 
  return (
    <PlateElement {...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 === activeContentKey,
                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;
 
                setSelectedContent({
                  key: item.key,
                  observedKey: activeKey,
                });
 
                const root = isScrollable ? container : document.body;
 
                if (!root) return;
 
                if (isScroll) {
                  const top =
                    element.getBoundingClientRect().top +
                    root.scrollTop -
                    root.getBoundingClientRect().top -
                    topOffset;
 
                  if (isScrollable) {
                    container?.scrollTo({ behavior: 'smooth', top });
                  } else {
                    window.scrollTo({ behavior: 'smooth', top });
                  }
                }
 
                const path = editor.read.nodes.path(item.key);
 
                if (path) {
                  navigation.update.flashTarget({
                    target: { path, type: 'node' },
                  });
                }
              }}
              aria-current={
                item.key === activeContentKey ? '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}
    </PlateElement>
  );
}
 
export const TocKit = [
  TocPlugin.configure({
    component: TocElement,
    initialState: {
      // isScroll: true,
      topOffset: 80,
    },
  }),
];
'use client';
 
import { TocPlugin } from '@platejs/toc/react';
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import {
  type PlateElementProps,
  NavigationFeedbackPlugin,
  PlateElement,
  useEditor,
  useEditorPlugin,
  useEditorScrollElement,
  useEditorSelector,
  usePluginStore,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
 
const headingItemVariants








































































































































































































  • TocElement: Renders table of contents elements.

Add Kit

Add the kit to your plugins:

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




Manual Usage

Installation

pnpm add @platejs/basic-nodes @platejs/toc
pnpm add @platejs/basic-nodes @platejs/toc

Add Plugins

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

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











Configure Plugin

Configure the TocPlugin with custom component and scroll options.

import { TocPlugin } from '@platejs/toc/react';
import { HeadingPlugin,  HeadingPlugin } from '@platejs/basic-nodes/react';
import { createPlateEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement,  HeadingElement } from '@/components/editor/heading';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    HeadingPlugin.configure({ component: HeadingElement }),
    HeadingPlugin.configure({ component: HeadingElement }),
    HeadingPlugin.configure({ component: HeadingElement }),
    TocPlugin.configure({
      component: TocElement,
      initialState: {





  • .configure({ component }): Assigns TocElement to render table of contents elements.
  • initialState.topOffset: Sets the top offset when scrolling to headings.
  • initialState.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

  • If your scrolling element is EditorContainer, you can skip this step.
  • If you render a custom scroll container, register it with useEditorScrollElementRef(editor).
import { useEditor, useEditorScrollElementRef } from 'platejs/react';
 
function Layout() {
  const editor = useEditor();
  const scrollRef = useEditorScrollElementRef(editor);
 
  return (
    <main ref={scrollRef}>
      <EditorContainer>
        <PlateContent />
      </EditorContainer>
    </main>
  );
}

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

    Enable scrolling behavior.

    • Default: true

    Top offset when scrolling to heading.

    • Default: 80

    Custom function to query headings.

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().

=
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(props: PlateElementProps<typeof TocPlugin>) {
const headingElementsRef = React.useRef<
Record<string, IntersectionObserverEntry>
>({});
const headingKeysRef = React.useRef(new WeakMap<Element, NodeKey>());
const editor = useEditor();
const navigation = useEditorPlugin(NavigationFeedbackPlugin);
const isScroll = usePluginStore(TocPlugin, 'isScroll');
const topOffset = usePluginStore(TocPlugin, 'topOffset');
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 scrollContainer =
typeof window === 'object'
? isScrollable
? container
: window
: undefined;
const [status, setStatus] = React.useState(0);
const [activeKey, setActiveKey] = React.useState<NodeKey | null>(null);
const [selectedContent, setSelectedContent] = React.useState<{
key: NodeKey;
observedKey: NodeKey | null;
}>();
const activeContentKey =
selectedContent?.observedKey === activeKey
? selectedContent.key
: activeKey;
React.useEffect(() => {
const observer = new IntersectionObserver(
(headings) => {
headingElementsRef.current = headings.reduce((map, heading) => {
const key = headingKeysRef.current.get(heading.target);
if (key) map[key] = heading;
return map;
}, headingElementsRef.current);
const firstVisible = Object.keys(headingElementsRef.current).find(
(key) => headingElementsRef.current[key].isIntersecting
);
if (firstVisible) setActiveKey(firstVisible as NodeKey);
headingElementsRef.current = {};
},
{
root: isScrollable ? container : undefined,
rootMargin: '0px 0px 0px 0px',
}
);
headingList.forEach(({ key }) => {
const node = editor.read.nodes.get(key)?.[0];
if (!node) return;
const element = editor.api.dom.resolveDOMNode(node);
if (element) {
headingKeysRef.current.set(element, key);
observer.observe(element);
}
});
return () => {
observer.disconnect();
};
}, [container, editor, headingList, isScrollable, status]);
React.useEffect(() => {
if (!scrollContainer) return undefined;
const scroll = () => {
setStatus(Date.now());
};
scrollContainer.addEventListener('scroll', scroll);
return () => {
scrollContainer.removeEventListener('scroll', scroll);
};
}, [scrollContainer]);
return (
<PlateElement {...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 === activeContentKey,
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;
setSelectedContent({
key: item.key,
observedKey: activeKey,
});
const root = isScrollable ? container : document.body;
if (!root) return;
if (isScroll) {
const top =
element.getBoundingClientRect().top +
root.scrollTop -
root.getBoundingClientRect().top -
topOffset;
if (isScrollable) {
container?.scrollTo({ behavior: 'smooth', top });
} else {
window.scrollTo({ behavior: 'smooth', top });
}
}
const path = editor.read.nodes.path(item.key);
if (path) {
navigation.update.flashTarget({
target: { path, type: 'node' },
});
}
}}
aria-current={
item.key === activeContentKey ? '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}
</PlateElement>
);
}
export const TocKit = [
TocPlugin.configure({
component: TocElement,
initialState: {
// isScroll: true,
topOffset: 80,
},
}),
];
plugins: [
// ...otherPlugins,
...TocKit,
],
});
;
import { HeadingPlugin, HeadingPlugin } from '@platejs/basic-nodes/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
HeadingPlugin,
TocPlugin,
],
});
topOffset:
80
,
isScroll: true,
},
}),
],
});
import { TocPlugin } from '@platejs/toc/react';
import { HeadingPlugin,  HeadingPlugin } from '@platejs/basic-nodes/react';
import { createPlateEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement,  HeadingElement } from '@/components/editor/heading';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    HeadingPlugin.configure({ component: HeadingElement }),
    HeadingPlugin.configure({ component: HeadingElement }),
    HeadingPlugin.configure({ component: HeadingElement }),
    TocPlugin.configure({
      component: TocElement,
      initialState: {
        topOffset: 80,
        isScroll: true,
      },
    }),
  ],
});
import { useEditor, useEditorScrollElementRef } from 'platejs/react'; function Layout() { const editor = useEditor(); const scrollRef = useEditorScrollElementRef(editor); return ( <main ref={scrollRef}> <EditorContainer> <PlateContent /> </EditorContainer> </main> ); }