Table of Contents
The @domternal/extension-toc package adds a Notion-style Table of Contents. It ships three pieces that work together:
TableOfContents- the headless heading observer extension. Watches the document for headings, maintains a reactive heading list in storage, exposesscrollToHeading(id)as a command, runswalkHeadings/createActiveStateTrackerhelpers, and handles initial-load#hashnavigation.FloatingTocOutline- the sticky outline UI extension. Renders a vertical tick column with a hover-expanded card showing heading labels. Supports two anchor modes:'editor'(sits in the editor’s right gutter) and'viewport'(fixed to the viewport edge).TableOfContentsBlock- the inline/tocatom node. Renders a reactive list of headings inline in the document.
All three share the same heading list from editor.storage.toc.content, populated by the heading observer plugin.
Installation
Section titled “Installation”pnpm add @domternal/extension-tocnpm install @domternal/extension-tocyarn add @domternal/extension-tocQuickstart
Section titled “Quickstart”import { Editor, StarterKit, UniqueID } from '@domternal/core';import { TableOfContents, FloatingTocOutline, TableOfContentsBlock,} from '@domternal/extension-toc';import '@domternal/theme';
const editor = new Editor({ element: document.getElementById('editor')!, extensions: [ StarterKit, UniqueID.configure({ types: ['heading'] }), TableOfContents, FloatingTocOutline.configure({ anchor: 'editor' }), TableOfContentsBlock, ],});The UniqueID extension is configured to assign ids to headings. TableOfContents watches the document; FloatingTocOutline renders the sticky outline; TableOfContentsBlock adds the /toc atom node so users can insert an inline TOC block.
TableOfContents
Section titled “TableOfContents”The headless heading observer. Maintains a reactive heading list, exposes the scrollToHeading command, and handles initial-load #hash navigation.
Options
Section titled “Options”TableOfContents.configure({ levels: [1, 2, 3], // heading levels to track anchorTypes: ['heading'], // node types treated as anchors onUpdate: (storage) => {}, // called when content list changes})| Option | Type | Default | Description |
|---|---|---|---|
levels | number[] | [1, 2, 3] | Heading levels to include in the TOC |
anchorTypes | string[] | ['heading'] | Node types treated as anchors. Custom heading-like nodes must also be added to UniqueID.types |
onUpdate | (storage: TocStorage) => void | undefined | Called when the heading list changes |
Storage
Section titled “Storage”interface TocStorage { content: HeadingEntry[]; // current heading list activeId: string | null; // id of the currently-active heading subscribers: Set<() => void>; // fan-out listeners}
interface HeadingEntry { id: string; level: number; textContent: string; pos: number; domNode: HTMLElement | null; isActive: boolean; isScrolledOver: boolean;}Access via editor.storage.toc. Subscribe to changes by adding to subscribers or by passing onUpdate to options.
Commands
Section titled “Commands”editor.commands.scrollToHeading(id: string): boolean;Pure DOM operation - does not produce a transaction. Returns true if the heading was found and scrolled to, false otherwise. Internally opens any collapsed <details> ancestors along the way and updates the URL hash via history.replaceState.
Initial-load #hash navigation
Section titled “Initial-load #hash navigation”When the editor mounts, the plugin checks window.location.hash. If a heading with that id exists, it calls scrollToHeading(id) automatically. This integrates with the TableOfContentsBlock atom: navigating to /some-page#section-2 scrolls to that section even when the page contains an inline /toc block.
Active heading tracking
Section titled “Active heading tracking”Active state is computed by createActiveStateTracker using IntersectionObserver. Rule: the active heading is the LAST one whose top has crossed the viewport top (or first-visible as fallback). The active id is stored in editor.storage.toc.activeId and broadcast to subscribers.
Tunable via rootMargin on createActiveStateTracker (default '0px 0px -85% 0px' - bottom 85% of viewport ignored, so the active heading is “the most recent one scrolled past”).
FloatingTocOutline
Section titled “FloatingTocOutline”The sticky outline UI. Renders a thin tick column with a hover-expanded card. Two anchor modes:
'editor'(default) - outline sits inside the editor’s right gutter and sticks within the editor container. Best for Notion-style layouts where the outline scrolls with the editor.'viewport'- outline isposition: fixedto the right edge of the viewport, vertically centered. Best for full-page editor layouts.
Options
Section titled “Options”FloatingTocOutline.configure({ anchor: 'editor', // 'editor' | 'viewport' minHeadings: 2, // hide outline until at least N headings mobileBreakpoint: 1024, // hide outline below this viewport width outlineHost: undefined, // custom host element resolver activeRootMargin: '0px 0px -85% 0px', // IntersectionObserver rootMargin activeScrollParent: null, // null = viewport, or Element/Document clickOverrideMs: 500, // ms to ignore IO updates after a tick click hoverInDelay: 120, // ms before showing expanded card hoverOutDelay: 350, // ms before collapsing card})| Option | Type | Default | Description |
|---|---|---|---|
anchor | 'editor' | 'viewport' | 'editor' | Where the outline anchors |
minHeadings | number | 2 | Hide outline until at least this many headings exist |
mobileBreakpoint | number | 1024 | Hide outline below this viewport width (set to 0 to always show) |
outlineHost | (view) => HTMLElement | undefined | Custom resolver for the host element to mount into |
activeRootMargin | string | '0px 0px -85% 0px' | IntersectionObserver rootMargin for active tracking |
activeScrollParent | Element | Document | null | null | Scroll parent for active tracking (null = viewport) |
clickOverrideMs | number | 500 | Ms to ignore IO active updates after a tick click |
hoverInDelay | number | 120 | Ms before the expanded card appears on hover |
hoverOutDelay | number | 350 | Ms before the card collapses after hover-out |
Editor anchor mode state machine
Section titled “Editor anchor mode state machine”In anchor: 'editor' mode, the outline tracks the editor’s bottom edge:
- Editor extends below viewport (
data-bottom-visible="false"): outline isposition: stickywithtop: var(--dm-toc-mid-top, 50vh)- centered around 50vh. - Editor bottom is on-screen (
data-bottom-visible="true"): outline’s sticky top becomesvar(--dm-toc-editor-top, 1rem)- frozen at the top of the gutter.
Hover-expanded card
Section titled “Hover-expanded card”The tick column is always visible. On hover or focus-within, an expanded card appears with text labels for every heading. Per-heading rendering uses data-level attributes so theme rules can apply per-level indent and per-level tick width.
Accessibility
Section titled “Accessibility”- Ticks:
role="button"witharia-label="<text> (heading <level>)" - Expanded rows:
role="button"witharia-current="location"on the active row - Single item gets
.dm-toc--active+aria-current="location" - Reduced motion:
prefers-reduced-motion: reduceremoves scroll animation - Forced colors:
@supports (forced-colors: active)guards ensure contrast focus-withinreveals the card without hover (keyboard users)
CSS classes
Section titled “CSS classes”.dm-toc-outline- root nav container.dm-toc-outline-shell- outer wrapper (editor mode only, runs full editor height).dm-toc-outline-tick- individual tick button (compact column).dm-toc-outline-card- expanded-view container.dm-toc-outline-row- item inside the card.dm-toc--active- applied to both ticks AND rows when active
CSS custom properties
Section titled “CSS custom properties”| Property | Default | Purpose |
|---|---|---|
--dm-toc-mid-top | 50vh | Sticky top (editor mode, middle state) |
--dm-toc-editor-top | 1rem | Sticky top (editor mode, frozen state) |
--dm-toc-right-offset | 24px | Right margin (viewport mode) |
--dm-toc-card-bg/shadow/radius | (theme) | Card visual |
--dm-toc-tick-{h1-h6}-width | (theme) | Per-level tick width (h1 widest, h6 narrowest) |
--dm-toc-tick-height/radius/gap | (theme) | Tick visual |
TableOfContentsBlock
Section titled “TableOfContentsBlock”The inline /toc atom node. Renders a reactive list of headings inside the document. Useful for putting a “Contents” section at the top of a doc.
Options
Section titled “Options”TableOfContentsBlock.configure({ emptyStateText: 'Add headings to create a table of contents.', HTMLAttributes: {},})| Option | Type | Default | Description |
|---|---|---|---|
emptyStateText | string | 'Add headings to create a table of contents.' | Placeholder text shown when the document has no headings |
HTMLAttributes | Record<string, unknown> | {} | Extra HTML attributes applied to the rendered wrapper |
Schema
Section titled “Schema”Atom node, group: "block", no content. The heading list is derived from editor.storage.toc, not from node attributes.
Inserting
Section titled “Inserting”Slash command: type /toc (when SlashCommand is loaded) and select “Table of contents”. The atom node inserts at the cursor and immediately renders the current heading list.
You can also insert programmatically:
editor.chain().focus().insertContent({ type: 'tableOfContentsBlock' }).run();Initial-load #hash integration
Section titled “Initial-load #hash integration”When the block mounts and the page URL has a #hash, the block calls scrollToHeading(view, hash) automatically. This is what lets /some-page#section-2 work end-to-end: TOC observer + inline block + URL hash combine to deep-link into any heading.
CSS classes
Section titled “CSS classes”.dm-toc-block- root wrapper.dm-toc-block-list-<ul>container.dm-toc-block-item-<li>per heading.dm-toc-block-link- button link to heading.dm-toc-block-link--active- applied whenactiveIdmatches.dm-toc-block-empty- empty state<p>
Helper exports
Section titled “Helper exports”walkHeadings
Section titled “walkHeadings”function walkHeadings(doc: PMNode, options: HeadingWalkOptions): HeadingWalkEntry[];
interface HeadingWalkOptions { levels: number[]; anchorTypes: string[]; attrName: string; // typically 'id', read from UniqueID's attributeName option}
interface HeadingWalkEntry { id: string; level: number; textContent: string; pos: number;}Walks the document tree once and returns the heading list. Returns an empty array entry for headings without ids (rare but possible during initial render before UniqueID assigns ids). Recurses into nested structures (e.g. headings inside <details>).
scrollToHeading
Section titled “scrollToHeading”function scrollToHeading( view: EditorView, id: string, options?: { behavior?: ScrollBehavior; // 'auto' | 'smooth' updateHash?: boolean; // default true attrName?: string; // default 'id' }): boolean;- CSS-escapes the id and queries
[<attrName>="<id>"]scoped toview.dom - Opens collapsed
<details>ancestors along the way - Calls
element.scrollIntoView({ behavior, block: 'start' }) - Updates URL hash via
history.replaceStateunlessupdateHash: false - Respects
prefers-reduced-motion: reduce(downgrades'smooth'to'auto') - Returns
trueif found,falseif not
createActiveStateTracker
Section titled “createActiveStateTracker”function createActiveStateTracker(options: ActiveStateTrackerOptions): ActiveStateTracker;
interface ActiveStateTrackerOptions { scrollParent?: Element | Document | null; // null = viewport rootMargin?: string; // default '0px 0px -85% 0px' attrName?: string; // default 'id' onChange: (activeId: string | null) => void;}
interface ActiveStateTracker { observe: (elements: readonly HTMLElement[]) => void; destroy: () => void;}Use this directly if you want to build your own outline UI and reuse Domternal’s active-tracking rule (last heading whose top crossed viewport top + first-visible fallback).
All exports
Section titled “All exports”import { TableOfContents, tocPluginKey, FloatingTocOutline, floatingTocOutlinePluginKey, TableOfContentsBlock, walkHeadings, scrollToHeading, createActiveStateTracker,} from '@domternal/extension-toc';
import type { HeadingEntry, TableOfContentsOptions, TocStorage, HeadingWalkEntry, HeadingWalkOptions, ScrollToHeadingOptions, ActiveStateTracker, ActiveStateTrackerOptions, FloatingTocOutlineOptions, TableOfContentsBlockOptions,} from '@domternal/extension-toc';Source
Section titled “Source”@domternal/extension-toc - GitHub