Renders a table of contents element with clickable links to headings in the document.
Enter and SpaceTOC tracks headings with editor-scoped NodeKey values. It does not require
persisted element IDs or install ElementIdPlugin.
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 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({
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 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.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,
}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 (
Plugin for generating table of contents.
Scroll options belong to the copied view. The heading query belongs to TocPlugin.
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();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().
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 />,
}),
],
});