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.

Use TableOfContents when you need:

  • An auto-generated TOC built from <h1> to <h6> headings
  • Active-heading tracking that highlights the section the user is reading
  • Smooth-scroll click-to-jump anchors that work inside the editor

Notes:

  • Pair with UniqueID for stable anchor IDs that survive across renders
  • For docs-site sidebars (this site uses Starlight), the framework’s own TOC is usually better; this extension targets in-editor TOC
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,
TableOfContents,
FloatingTocOutline.configure({ anchor: 'editor' }),
TableOfContentsBlock,
],
});

UniqueID needs no configuration here: heading is already one of its 12 default types, and TOC reads that id as the navigation anchor. 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: 1, // 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
minHeadingsnumber1Hide outline until at least this many headings exist (raise to 2 to suppress single-tick outlines on title-only documents)
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; // default 'id', matches UniqueID's attributeName option
}
interface HeadingWalkEntry {
id: string;
level: number;
textContent: string;
pos: number;
}

Walks the document tree once and returns the heading list. Headings without a stored id still get an entry, with id: '', so the caller can filter them or surface them as unanchored (this happens 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

  • Heading - H1-H6 headings with # markdown shortcuts
  • Unique Id - stable IDs on block nodes for anchoring