Table of Contents Node

Available in Start plan

A comprehensive table of contents (TOC) component system for Tiptap editors. This package provides both an inline TOC node that can be embedded within the editor content and a floating sidebar component for document navigation with progress indicators.

Installation

Add the component via the Tiptap CLI:

npx @tiptap/cli@latest add toc-node

Usage

Basic Integration

To add table of contents functionality to your Tiptap editor, follow these steps:

1. Import the required extensions and components:

import { Heading } from '@tiptap/extension-heading'
import { TableOfContents } from '@tiptap/extension-table-of-contents'
import { TocNode, TocSidebar, TocShowTitleButton } from '@/components/tiptap-node/toc-node'
import { TocProvider, useToc } from '@/components/tiptap-node/toc-node/context/toc-context'

// Import required styles
import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

2. Add extensions to your editor configuration:

function EditorComponent() {
  const { setTocContent } = useToc()

  const editor = useEditor({
    extensions: [
      StarterKit,
      Heading.configure({
        levels: [1, 2, 3, 4, 5, 6],
      }),
      TableOfContents.configure({
        getIndex: (headingNode) => headingNode.attrs.id,
        onUpdate: (content) => {
          setTocContent(content) // Update TOC state
        },
      }),
      TocNode.configure({
        topOffset: 80,
        maxShowCount: 20,
        showTitle: true,
      }),
    ],
    content: '<p>Your content here</p>',
  })
}

3. Add the TOC components to your editor:

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocSidebar } from '@/components/tiptap-node/toc-node'
;<TocProvider>
  <EditorContext.Provider value={{ editor }}>
    {/* Toolbar with TOC button */}
    <div className="toolbar">
      <button onClick={() => editor?.commands.insertTocNode()}>Insert TOC</button>
      <TocShowTitleButton editor={editor} />
    </div>

    <div className="editor-layout">
      <EditorContent editor={editor} />

      {/* Floating sidebar */}
      <TocSidebar maxShowCount={20} topOffset={80} />
    </div>
  </EditorContext.Provider>
</TocProvider>

That's it! Your editor now has:

  • Inline TOC nodes that can be inserted into documents
  • Floating TOC sidebar with progress indicators
  • Automatic heading detection and navigation
  • URL hash support for deep linking

For a complete example with all features, see the Complete Example section below.

Creating Table of Contents

Inserting TOC Nodes

The TOC system provides two primary ways to display a table of contents:

1. Inline TOC Node

An inline node that can be placed anywhere in your document content:

// Insert programmatically
editor.commands.insertTocNode({
  maxShowCount: 10,
  topOffset: 60,
  showTitle: true,
})

2. Floating TOC Sidebar

A sidebar component that floats alongside your editor:

<TocSidebar maxShowCount={20} topOffset={80} className="my-toc-sidebar" />

Components

<TocNode /> Extension

An inline table of contents node that can be embedded directly in your document. This extension creates a draggable, selectable block that automatically updates as headings change.

Key Features

  • Auto-updating: Automatically reflects heading changes in the document
  • Customizable Display: Control max headings, title visibility, and scroll offset
  • Smart Navigation: Click to scroll to headings with smooth scrolling
  • Empty State: Shows helpful message when no headings exist
  • Depth Normalization: Proper hierarchical nesting of headings

Usage

import { TocNode } from '@/components/tiptap-node/toc-node'
import { TableOfContents } from '@tiptap/extension-table-of-contents'

const editor = useEditor({
  extensions: [
    TableOfContents.configure({
      getIndex: (headingNode) => headingNode.attrs.id,
      onUpdate: (content) => {
        // Handle updates
      },
    }),
    TocNode.configure({
      topOffset: 80,
      maxShowCount: 20,
      showTitle: true,
      HTMLAttributes: {
        class: 'my-custom-toc',
      },
    }),
  ],
})

// Insert the node
editor.commands.insertTocNode({
  maxShowCount: 15,
  topOffset: 60,
})

Configuration Options

OptionTypeDefaultDescription
topOffsetnumber0Offset from the top of the viewport when scrolling to a heading (in pixels)
maxShowCountnumber20Maximum number of headings to display in the TOC
showTitlebooleantrueWhether to show the "Table of contents" title
HTMLAttributesRecord<string, any>{}HTML attributes to add to the TOC node element

Commands

The extension provides the insertTocNode command:

// Insert with default settings
editor.commands.insertTocNode()

// Insert with custom attributes
editor.commands.insertTocNode({
  maxShowCount: 10,
  topOffset: 100,
  showTitle: false,
})

<TocSidebar />

A floating sidebar component that displays the table of contents with visual progress indicators. This component provides an always-visible navigation panel with advanced state management and smooth scrolling.

Key Features

  • Progress Rail: Visual progress indicator showing document position
  • Multi-level Active State:
    1. Manual clicks (highest priority)
    2. Scroll position tracking
    3. Cursor/selection position
    4. First heading (fallback)
  • Smart Scrolling: Only scrolls when target heading is not visible
  • URL Hash Support: Restores scroll position from URL on page load
  • Smooth Transitions: Animated active state changes

Usage

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocSidebar } from '@/components/tiptap-node/toc-node'

function MyEditor() {
  return (
    <TocProvider>
      <div className="editor-container">
        <EditorContent editor={editor} />
        <TocSidebar maxShowCount={20} topOffset={80} className="custom-sidebar" />
      </div>
    </TocProvider>
  )
}

Props

PropTypeDefaultDescription
maxShowCountnumber20Maximum number of headings to show in the TOC
topOffsetnumber0Offset from the top of the editor container (in pixels)
classNamestring-Additional CSS class names
All standard HTML div props--Supports all HTMLAttributes<HTMLDivElement>

Active State Logic

The sidebar intelligently determines which heading to highlight:

  1. Manual Click: When a user clicks a TOC item, that item remains active
  2. Scroll Detection: After manual navigation completes, switches to scroll-based detection
  3. Cursor Position: Highlights heading where the cursor is currently positioned
  4. Fallback: Shows first heading when no other criteria are met

<TocShowTitleButton />

A toolbar button for toggling the visibility of the TOC node title. This button only appears when a TOC node is selected in the editor.

Usage

import { TocShowTitleButton } from '@/components/tiptap-node/toc-node'

function MyToolbar() {
  return (
    <div className="toolbar">
      <TocShowTitleButton
        editor={editor}
        text="Show Title"
        hideWhenUnavailable={true}
        onToggle={(isActive) => {
          console.log('Title visibility:', isActive)
        }}
      />
    </div>
  )
}

Props

PropTypeDefaultDescription
editorEditor | null-The TipTap editor instance
textstring-Optional text to display alongside the icon
hideWhenUnavailablebooleanfalseHide the button when no TOC node is selected
onToggle(active: boolean) => void-Callback fired when the toggle state changes
All standard button props--Supports all ButtonProps

Custom Implementation with Hook

For custom button implementations, use the useTocShowTitle hook:

import { useTocShowTitle } from '@/components/tiptap-node/toc-node'

function CustomShowTitleButton() {
  const { isVisible, isActive, canToggle, handleToggle, label, Icon } = useTocShowTitle({
    editor,
    hideWhenUnavailable: true,
    onToggle: (isActive) => {
      console.log('Title visibility:', isActive)
    },
  })

  if (!isVisible) return null

  return (
    <button onClick={handleToggle} disabled={!canToggle} aria-label={label} data-active={isActive}>
      <Icon /> {label}
    </button>
  )
}

The hook returns:

  • isVisible: boolean - Whether the button should be shown
  • isActive: boolean - Whether the title is currently visible
  • canToggle: boolean - Whether the toggle action can be performed
  • handleToggle: () => boolean - Execute the toggle action
  • label: string - UI label for the button
  • Icon: ComponentType - Icon component for the button

Context & Hooks

<TocProvider />

A context provider that manages TOC state and provides navigation methods to all child components.

Usage

import { TocProvider } from '@/components/tiptap-node/toc-node/context/toc-context'

function App() {
  return (
    <TocProvider>
      <YourEditorComponents />
    </TocProvider>
  )
}

useToc()

Access TOC state and methods from any component within the provider.

API

const {
  tocContent, // TableOfContentData | null
  setTocContent, // (value: TableOfContentData | null) => void
  navigateToHeading, // (item, options?) => void
  normalizeHeadingDepths, // <T>(items: T[]) => number[]
} = useToc()

Methods

navigateToHeading(item, options?)

Scrolls to a heading and updates the editor selection.

navigateToHeading(item, {
  topOffset: 80, // Offset from top (pixels)
  behavior: 'smooth', // 'smooth' | 'auto'
})

Parameters:

  • item: TableOfContentDataItem - The heading item to navigate to
  • options.topOffset?: number - Offset from viewport top (default: 0)
  • options.behavior?: ScrollBehavior - Scroll behavior (default: 'smooth')

normalizeHeadingDepths(items)

Normalizes heading depths for proper hierarchical nesting. This ensures headings can only be nested under previous headings with smaller level numbers, preventing incorrect structures.

const items = [
  { level: 2, textContent: 'Section 1' },
  { level: 4, textContent: 'Subsection' }, // h4 can't be child of h2
  { level: 3, textContent: 'Another Section' },
]

const depths = normalizeHeadingDepths(items)
// Returns: [1, 2, 2]
// The h4 is adjusted to be at the same depth as a properly nested h3

Algorithm:

  1. Rebases all levels so the minimum level becomes 1 (root level)
  2. For each heading, finds the most recent previous heading with a smaller level
  3. If found, nests it under that parent (parent depth + 1)
  4. If not found, treats it as a root-level item (depth = 1)

Complete Example

Here's a comprehensive example integrating all components:

import { useEditor, EditorContent, EditorContext } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { Heading } from '@tiptap/extension-heading'
import { TableOfContents } from '@tiptap/extension-table-of-contents'
import { TocProvider, useToc } from '@/components/tiptap-node/toc-node/context/toc-context'
import { TocNode, TocSidebar, TocShowTitleButton } from '@/components/tiptap-node/toc-node'

import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

function EditorComponent() {
  const { setTocContent } = useToc()

  const editor = useEditor({
    extensions: [
      StarterKit,
      Heading.configure({
        levels: [1, 2, 3, 4, 5, 6],
      }),
      TableOfContents.configure({
        getIndex: (headingNode) => headingNode.attrs.id,
        onUpdate: (content) => {
          setTocContent(content)
        },
      }),
      TocNode.configure({
        topOffset: 80,
        maxShowCount: 20,
        showTitle: true,
      }),
    ],
    content: `
      <h1 id="introduction">Introduction</h1>
      <p>Welcome to this comprehensive document about table of contents...</p>
      <h2 id="getting-started">Getting Started</h2>
      <p>Let's begin by installing the required packages...</p>
      <h3 id="installation">Installation</h3>
      <p>Run the following command to install...</p>
      <h3 id="configuration">Configuration</h3>
      <p>Configure your editor with these options...</p>
      <h2 id="usage">Usage</h2>
      <p>Here's how to use the TOC components...</p>
      <h3 id="inline-toc">Inline TOC Node</h3>
      <p>Insert a TOC node directly in your document...</p>
      <h3 id="sidebar-toc">Sidebar TOC</h3>
      <p>Add a floating sidebar for navigation...</p>
      <h2 id="advanced">Advanced Features</h2>
      <p>Explore advanced functionality...</p>
    `,
  })

  return (
    <EditorContext.Provider value={{ editor }}>
      <div className="editor-wrapper">
        {/* Toolbar */}
        <div className="toolbar">
          <button
            onClick={() => editor?.commands.insertTocNode()}
            disabled={!editor?.can().insertTocNode()}
          >
            Insert TOC
          </button>
          <TocShowTitleButton editor={editor} text="Toggle Title" hideWhenUnavailable={true} />
        </div>

        {/* Editor with Sidebar */}
        <div className="editor-content-wrapper">
          <EditorContent editor={editor} className="editor-content" />
          <TocSidebar maxShowCount={20} topOffset={80} className="editor-toc-sidebar" />
        </div>
      </div>
    </EditorContext.Provider>
  )
}

export default function App() {
  return (
    <TocProvider>
      <EditorComponent />
    </TocProvider>
  )
}

Styling

The TOC components include default styles that can be customized using CSS or SCSS.

Required Stylesheets

import '@/components/tiptap-node/toc-node/toc-node.scss'
import '@/components/tiptap-node/toc-node/ui/toc-sidebar/toc-sidebar.scss'

TocNode CSS Classes

.tiptap-table-of-contents-node {
  // Main container for the TOC node
  padding: 1rem;
  border: 1px solid var(--border-color);
  border-radius: 0.5rem;
}

.tiptap-table-of-contents-title {
  // Title heading "Table of contents"
  font-size: 1.25rem;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

.tiptap-table-of-contents-list {
  // Navigation list container
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.tiptap-table-of-contents-item {
  // Individual TOC items
  padding: 0.5rem;
  border-radius: 0.25rem;
  text-decoration: none;
  color: var(--text-color);
  transition: background-color 0.2s;

  &:hover {
    background-color: var(--hover-bg);
  }

  // Depth-based indentation
  &[data-depth='1'] {
    padding-left: 0.5rem;
  }
  &[data-depth='2'] {
    padding-left: 1.5rem;
  }
  &[data-depth='3'] {
    padding-left: 2.5rem;
  }
}

.tiptap-table-of-contents-empty {
  // Empty state message
  color: var(--text-muted);
  font-style: italic;
}

TocSidebar CSS Classes

.toc-sidebar {
  // Main sidebar container
  position: sticky;
  top: 80px;
  max-height: calc(100vh - 100px);
  overflow-y: auto;
}

.toc-sidebar-wrapper {
  // Inner wrapper
  display: flex;
  gap: 0.75rem;
}

.toc-sidebar-progress {
  // Progress rail container
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  width: 3px;
}

.toc-sidebar-progress-line {
  // Individual progress lines
  height: 1.5rem;
  background-color: var(--progress-inactive);
  transition: background-color 0.2s;

  &--active {
    background-color: var(--progress-active);
  }
}

.toc-sidebar-nav {
  // Navigation container
  flex: 1;
}

.toc-sidebar-item {
  // Sidebar navigation items
  display: block;
  padding: 0.375rem 0.5rem;
  border-radius: 0.25rem;
  text-decoration: none;
  color: var(--text-color);
  font-size: 0.875rem;
  line-height: 1.5;
  transition: all 0.2s;

  &:hover {
    background-color: var(--hover-bg);
  }

  &--active {
    font-weight: 600;
    color: var(--active-color);
  }
}

Custom Depth Styling

Both components expose a --toc-depth CSS variable for custom indentation:

.tiptap-table-of-contents-item {
  // Use CSS variable for dynamic indentation
  padding-left: calc(var(--toc-depth, 1) * 1rem);
}

.toc-sidebar-item {
  // Adjust for sidebar (depth 1 = no indent)
  padding-left: calc((var(--toc-depth, 1) - 1) * 0.75rem);

  // Optional: Add visual indicator for depth
  &::before {
    content: '';
    display: inline-block;
    width: calc(var(--toc-depth, 1) * 4px);
    height: 2px;
    background: currentColor;
    margin-right: 0.5rem;
    opacity: 0.3;
  }
}

Dark Mode Support

Add dark mode styles using CSS variables or class-based theming:

:root {
  --toc-bg: #ffffff;
  --toc-text: #1a1a1a;
  --toc-border: #e5e5e5;
  --toc-hover: #f5f5f5;
  --toc-active: #0066ff;
  --toc-progress-inactive: #e5e5e5;
  --toc-progress-active: #0066ff;
}

.dark {
  --toc-bg: #1a1a1a;
  --toc-text: #ffffff;
  --toc-border: #333333;
  --toc-hover: #2a2a2a;
  --toc-active: #4d9aff;
  --toc-progress-inactive: #333333;
  --toc-progress-active: #4d9aff;
}

Dependencies

Required Packages

  • @tiptap/core - Core TipTap functionality
  • @tiptap/react - React integration for TipTap
  • @tiptap/extension-table-of-contents - TOC content extraction
  • @tiptap/extension-heading - Heading nodes (for TOC sources)
  • react and react-dom - React framework

Optional Packages

  • @tiptap/extension-unique-id - Automatic heading ID generation
  • sass / sass-embedded - For SCSS compilation (if using SCSS)

Features

  • Inline TOC Node: Embed table of contents directly in documents
  • Floating Sidebar: Always-visible navigation panel
  • Auto-updating: Real-time updates as headings change
  • Smart Navigation: Intelligent scroll and active state management
  • Progress Indicators: Visual progress rail showing document position
  • Multi-level Detection: Click, scroll, and cursor-based active states
  • Depth Normalization: Proper hierarchical heading structure
  • URL Hash Support: Restore scroll position from URL
  • Empty State: Helpful message when no headings exist
  • Smooth Scrolling: Animated scroll-to-heading transitions
  • Configurable Display: Control max items, title visibility, scroll offset
  • Keyboard Accessible: Full keyboard navigation support
  • Dark Mode: Built-in dark mode theming
  • TypeScript: Comprehensive type definitions
  • Customizable Styling: CSS variables and class-based theming
  • Heading Extension - Required for creating heading nodes
  • UniqueID Extension - Recommended for stable heading IDs
  • Table of Contents Extension - Required for content extraction