Skip to content

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, exposes scrollToHeading(id) as a command, runs walkHeadings/createActiveStateTracker helpers, and handles initial-load #hash navigation.
  • 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 /toc atom 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.

Terminal window
pnpm add @domternal/extension-toc
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.


The headless heading observer. Maintains a reactive heading list, exposes the scrollToHeading command, and handles initial-load #hash navigation.

TableOfContents.configure({
levels: [1, 2, 3], // heading levels to track
anchorTypes: ['heading'], // node types treated as anchors
onUpdate: (storage) => {}, // called when content list changes
})
OptionTypeDefaultDescription
levelsnumber[][1, 2, 3]Heading levels to include in the TOC
anchorTypesstring[]['heading']Node types treated as anchors. Custom heading-like nodes must also be added to UniqueID.types
onUpdate(storage: TocStorage) => voidundefinedCalled when the heading list changes
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.

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.

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


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 is position: fixed to the right edge of the viewport, vertically centered. Best for full-page editor layouts.
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
})
OptionTypeDefaultDescription
anchor'editor' | 'viewport''editor'Where the outline anchors
minHeadingsnumber2Hide outline until at least this many headings exist
mobileBreakpointnumber1024Hide outline below this viewport width (set to 0 to always show)
outlineHost(view) => HTMLElementundefinedCustom resolver for the host element to mount into
activeRootMarginstring'0px 0px -85% 0px'IntersectionObserver rootMargin for active tracking
activeScrollParentElement | Document | nullnullScroll parent for active tracking (null = viewport)
clickOverrideMsnumber500Ms to ignore IO active updates after a tick click
hoverInDelaynumber120Ms before the expanded card appears on hover
hoverOutDelaynumber350Ms before the card collapses after hover-out

In anchor: 'editor' mode, the outline tracks the editor’s bottom edge:

  • Editor extends below viewport (data-bottom-visible="false"): outline is position: sticky with top: var(--dm-toc-mid-top, 50vh) - centered around 50vh.
  • Editor bottom is on-screen (data-bottom-visible="true"): outline’s sticky top becomes var(--dm-toc-editor-top, 1rem) - frozen at the top of the gutter.

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.

  • Ticks: role="button" with aria-label="<text> (heading <level>)"
  • Expanded rows: role="button" with aria-current="location" on the active row
  • Single item gets .dm-toc--active + aria-current="location"
  • Reduced motion: prefers-reduced-motion: reduce removes scroll animation
  • Forced colors: @supports (forced-colors: active) guards ensure contrast
  • focus-within reveals the card without hover (keyboard users)
  • .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
PropertyDefaultPurpose
--dm-toc-mid-top50vhSticky top (editor mode, middle state)
--dm-toc-editor-top1remSticky top (editor mode, frozen state)
--dm-toc-right-offset24pxRight 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

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.

TableOfContentsBlock.configure({
emptyStateText: 'Add headings to create a table of contents.',
HTMLAttributes: {},
})
OptionTypeDefaultDescription
emptyStateTextstring'Add headings to create a table of contents.'Placeholder text shown when the document has no headings
HTMLAttributesRecord<string, unknown>{}Extra HTML attributes applied to the rendered wrapper

Atom node, group: "block", no content. The heading list is derived from editor.storage.toc, not from node attributes.

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

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.

  • .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 when activeId matches
  • .dm-toc-block-empty - empty state <p>

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

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 to view.dom
  • Opens collapsed <details> ancestors along the way
  • Calls element.scrollIntoView({ behavior, block: 'start' })
  • Updates URL hash via history.replaceState unless updateHash: false
  • Respects prefers-reduced-motion: reduce (downgrades 'smooth' to 'auto')
  • Returns true if found, false if not
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).


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';

@domternal/extension-toc - GitHub