🎁 100 free AI Toolkit licenses – apply by August 15.Learn more

Highlight Search Terms with Decorations in React

In this tutorial, you will build a term highlighter for a Tiptap editor in React. The user types a search term into an input field, and every match in the document gets highlighted. Nothing about the document itself changes. The highlights are purely visual.

The finished feature uses all three decoration types:

  • Inline decorations highlight every match with a yellow background.
  • Node decorations outline every block that contains at least one match.
  • Widget decorations render a small numbered React badge after each match.

What are decorations?

Decorations are visual markers that sit on top of the document. They change what the user sees, but they do not change the document content. When you save the editor as JSON or HTML, decorations are not included.

Tiptap has three kinds of decoration:

  • Inline wraps a range of text in a styled span. Use it for highlights.
  • Node adds attributes (like a CSS class) to the DOM wrapper of a whole block. Use it to outline paragraphs or headings.
  • Widget inserts a DOM element at a single position. Use it for badges, markers, or small UI elements.

You create all three with the Decoration class, imported from @tiptap/core. For widgets that render React components, you use ReactWidgetRenderer from @tiptap/react.

Set up the editor and search input

Start with a basic React component that renders a search input and the editor:

// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'

import { Highlight } from './Highlight'
import './styles.css'

export function TiptapEditor() {
  // We store the search term in React state so the input stays controlled
  const [searchTerm, setSearchTerm] = useState('')

  const editor = useEditor({
    extensions: [StarterKit, Highlight],
    content: `
      <h2>Tiptap decorations tutorial</h2>
      <p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
      <p>You can highlight words without changing the document.</p>
      <p>Try typing "tiptap" in the search box above.</p>
    `,
  })

  if (!editor) {
    return null
  }

  return (
    <div>
      <div className="highlight-toolbar">
        <input
          aria-label="Search"
          placeholder="Type a word to highlight..."
          value={searchTerm}
          onChange={(event) => setSearchTerm(event.target.value)}
        />
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}

The Highlight extension does not exist yet. We will create it next. The input is wired to React state but not yet connected to the editor. We will do that after the extension is ready.

Create the highlight extension

Extensions are the building blocks of Tiptap. Every feature, from bold text to code blocks, lives in an extension. We need a custom extension that reads a search term and produces decorations.

Start with an empty extension and a storage field for the search term:

// Highlight.tsx
import { Extension } from '@tiptap/core'

// This tells TypeScript what our storage looks like
interface HighlightStorage {
  term: string
}

// Tiptap keeps every extension's storage in one shared object: editor.storage.
// TypeScript does not know about our "highlight" key by default, so we use
// declare module to add it. This gives us type checking on editor.storage.highlight.
declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  // addStorage returns the initial values for this extension's storage.
  // We store the search term here so the decoration logic can read it.
  addStorage() {
    return {
      term: '',
    }
  },

  // addCommands defines custom commands callable via editor.commands.
  // Writing to storage alone does not trigger a decoration rebuild, so we
  // create one command that stores the term and refreshes decorations together.
  addCommands() {
    return {
      setSearchTerm: (term: string) => ({ editor, commands }) => {
        editor.storage.highlight.term = term
        commands.updateDecorations('highlight')
        return true
      },
    }
  },
})

Storage is a plain object that lives on the extension instance. Writing to it alone does not trigger a decoration rebuild, so we added a setSearchTerm command that stores the term and calls updateDecorations in one step. We will call it from the React component later. Inside addDecorations, we read the term from storage.

Create the badge widget component

Before we add decorations, let us create the React component that the widget will render. It is a small numbered badge that appears after each match.

ReactWidgetRenderer automatically passes editor and getPos to your component as props, alongside any props you provide. For this badge we only need the match number:

// MatchBadge.tsx
import type { ReactWidgetDecorationProps } from '@tiptap/react'

// ReactWidgetDecorationProps gives us editor and getPos.
// We add our own prop: the match number to display.
interface MatchBadgeProps extends ReactWidgetDecorationProps {
  matchNumber: number
}

export function MatchBadge({ matchNumber }: MatchBadgeProps) {
  return (
    <span className="match-badge" contentEditable={false}>
      {matchNumber}
    </span>
  )
}

The contentEditable={false} prop is important. Without it, the user could type inside the badge, which would confuse ProseMirror's editing logic.

What are editor and getPos?

Every widget component receives editor (the Tiptap editor instance) and getPos (a function that returns the widget's current document position). We do not need them for a static badge, but they are essential for interactive widgets. For example, a "replace" button would use getPos() to know which part of the document to replace. Always call getPos() when you need the position, never store its result, because the position changes as the user edits.

Find and highlight matches

Now we add the addDecorations hook to the extension. This is where we scan the document for matches and return decorations.

We build it up in three steps: inline decorations first, then node decorations, then widget decorations.

Step 1: Inline decorations for highlighting

// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'

// ... HighlightStorage, declare module, addStorage, and addCommands stay the same ...

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  addStorage() {
    return { term: '' }
  },

  addDecorations() {
    return {
      // 'manual' means Tiptap will not rebuild decorations on every keystroke.
      // We decide when to rebuild by calling editor.commands.updateDecorations().
      update: 'manual',

      // create runs on init and every time we call updateDecorations().
      create: ({ editor, state }) => {
        // Read the search term from storage and clean it up
        const term = editor.storage.highlight.term.trim().toLowerCase()

        // If there is no search term, return an empty array (no decorations)
        if (!term) return []

        const decorations: Decoration[] = []

        // descendants() walks every node in the document.
        // Each node comes with its starting position (pos).
        state.doc.descendants((node, pos) => {
          // We only care about text nodes. Skip headings, paragraphs, etc.
          if (!node.isText || !node.text) return

          // Search inside this text node (case-insensitive)
          const text = node.text.toLowerCase()
          let index = text.indexOf(term)

          // Keep searching until we run out of matches in this text node
          while (index !== -1) {
            // pos is where the text node starts.
            // index is where the match starts inside the text node.
            // So the match's document position is pos + index.
            const matchFrom = pos + index
            const matchTo = matchFrom + term.length

            // Create an inline decoration that wraps the match in a span
            // with the CSS class "highlight-match"
            decorations.push(
              Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
            )

            // Move past this match and look for the next one
            index = text.indexOf(term, index + term.length)
          }
        })

        return decorations
      },
    }
  },
})

Why use the manual update strategy?

The search term comes from outside the document (an input field). When the user types in the editor, the document changes but the search term does not. There is no reason to rebuild decorations on every keystroke. With update: 'manual', Tiptap only maps existing decorations to their new positions (which is fast) and waits for us to call updateDecorations() when the search term actually changes.

Step 2: Node decorations for outlining blocks

Inline decorations highlight individual words. Node decorations can outline the entire block (paragraph, heading, etc.) that contains a match. We track which blocks we have already decorated to avoid duplicates:

// Inside create, before the descendants() call, add a Set to track decorated blocks:

create: ({ editor, state }) => {
  const term = editor.storage.highlight.term.trim().toLowerCase()
  if (!term) return []

  const decorations: Decoration[] = []
  // Track block start positions we have already outlined
  const decoratedBlocks = new Set<number>()

  state.doc.descendants((node, pos) => {
    if (!node.isText || !node.text) return

    const text = node.text.toLowerCase()
    let index = text.indexOf(term)

    while (index !== -1) {
      const matchFrom = pos + index
      const matchTo = matchFrom + term.length

      // Inline: highlight the match
      decorations.push(
        Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
      )

      // Node: outline the block that contains this match.
      // resolve() gives us info about the position, including which block it sits in.
      const $match = state.doc.resolve(matchFrom)

      // depth is how deeply the match is nested: 1 is a top-level block,
      // 2 is a block inside another block. The calculation selects the
      // innermost block around the match.
      const depth = Math.max(1, $match.depth)

      // before() and after() give us the start and end of the containing block
      const blockStart = $match.before(depth)
      const blockEnd = $match.after(depth)

      // Only outline each block once, even if it has multiple matches
      if (!decoratedBlocks.has(blockStart)) {
        decoratedBlocks.add(blockStart)
        decorations.push(
          Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
        )
      }

      index = text.indexOf(term, index + term.length)
    }
  })

  return decorations
},

Step 3: Widget decorations for the numbered badge

Now we add the widget. We use ReactWidgetRenderer to render the MatchBadge component we created earlier. We need a counter to number matches across all text nodes:

// Add the import at the top of Highlight.tsx:
import { ReactWidgetRenderer } from '@tiptap/react'
import { MatchBadge } from './MatchBadge'

// Inside create, add a counter before the descendants() call:
let matchNumber = 0

// Then inside the while loop, after the node decoration:

// Widget: render a numbered React badge after the match.
// ReactWidgetRenderer takes the component and an options object.
// The component receives the props we pass plus editor and getPos.
matchNumber++
decorations.push(
  ReactWidgetRenderer(MatchBadge, {
    editor,
    // Place the widget right after the match
    pos: matchTo,
    // The key identifies this widget across rebuilds.
    // Position-based keys are fine here because the badge has no state.
    // For stateful widgets, use a stable key like `match-${id}` instead.
    key: `match-badge-${matchFrom}`,
    // These props are passed to the MatchBadge component
    props: { matchNumber },
    // side: 1 places the widget after the match (right side)
    side: 1,
  }),
)

Widget keys explained

Every widget needs a key. ProseMirror uses the key to decide whether to reuse the widget's DOM across redraws or destroy and recreate it. If the key stays the same, the widget stays mounted and React preserves its state. If the key changes, the widget is recreated.

For stateless widgets like this badge, a position-based key (match-badge-${matchFrom}) is fine. For stateful widgets (anything that holds data the user changed), use a stable key tied to the item's identity, like comment-${id}. See the widget keys section in the core concepts guide.

The complete extension

Here is the full extension with all three decoration types:

// Highlight.tsx
import { Decoration, Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'

import { MatchBadge } from './MatchBadge'

interface HighlightStorage {
  term: string
}

// Augment Tiptap's Storage interface so editor.storage.highlight is typed
declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

export const Highlight = Extension.create<{}, HighlightStorage>({
  name: 'highlight',

  addStorage() {
    return { term: '' }
  },

  addCommands() {
    return {
      setSearchTerm: (term: string) => ({ editor, commands }) => {
        editor.storage.highlight.term = term
        commands.updateDecorations('highlight')
        return true
      },
    }
  },

  addDecorations() {
    return {
      update: 'manual',
      create: ({ editor, state }) => {
        const term = editor.storage.highlight.term.trim().toLowerCase()
        if (!term) return []

        const decorations: Decoration[] = []
        let matchNumber = 0
        const decoratedBlocks = new Set<number>()

        state.doc.descendants((node, pos) => {
          if (!node.isText || !node.text) return

          const text = node.text.toLowerCase()
          let index = text.indexOf(term)

          while (index !== -1) {
            const matchFrom = pos + index
            const matchTo = matchFrom + term.length

            // Inline: highlight the match
            decorations.push(
              Decoration.Inline(matchFrom, matchTo, { class: 'highlight-match' }),
            )

            // Node: outline the containing block once
            const $match = state.doc.resolve(matchFrom)
            const depth = Math.max(1, $match.depth)
            const blockStart = $match.before(depth)
            const blockEnd = $match.after(depth)

            if (!decoratedBlocks.has(blockStart)) {
              decoratedBlocks.add(blockStart)
              decorations.push(
                Decoration.Node(blockStart, blockEnd, { class: 'has-match' }),
              )
            }

            // Widget: numbered React badge after the match
            matchNumber++
            decorations.push(
              ReactWidgetRenderer(MatchBadge, {
                editor,
                pos: matchTo,
                key: `match-badge-${matchFrom}`,
                props: { matchNumber },
                side: 1,
              }),
            )

            index = text.indexOf(term, index + term.length)
          }
        })

        return decorations
      },
    }
  },
})

Connect the search input

Now we connect the input to the extension. When the search term changes, we write it into the extension's storage and tell Tiptap to rebuild the decorations:

// Editor.tsx
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useState } from 'react'

import { Highlight } from './Highlight'
import './styles.css'

export function TiptapEditor() {
  const [searchTerm, setSearchTerm] = useState('')
  const editor = useEditor({
    extensions: [StarterKit, Highlight],
    content: `
      <h2>Tiptap decorations tutorial</h2>
      <p>Tiptap is a headless editor toolkit built on ProseMirror.</p>
      <p>You can highlight words without changing the document.</p>
      <p>Try typing "tiptap" in the search box above.</p>
    `,
  })

  // Called every time the input value changes
  const onSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value
    setSearchTerm(value)

    if (!editor) return

    // Our custom command stores the term and rebuilds decorations.
    // No need to touch storage or call updateDecorations separately.
    editor.commands.setSearchTerm(value)
  }

  if (!editor) {
    return null
  }

  return (
    <div>
      <div className="highlight-toolbar">
        <input
          aria-label="Search"
          placeholder="Type a word to highlight..."
          value={searchTerm}
          onChange={onSearchChange}
        />
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}

The flow is: the user types, React state updates, the setSearchTerm command stores the term and triggers a rebuild, and Tiptap calls our create function which scans the document and returns new decorations.

Style the decorations

The decorations add CSS classes to the document. Add these styles to make them visible:

/* styles.css */

/* Inline decoration: yellow highlight on each match */
.highlight-match {
  background: #fff3a3;
  border-radius: 2px;
}

/* Node decoration: outline blocks that contain matches */
.has-match {
  outline: 2px solid #ffc857;
  outline-offset: 2px;
  border-radius: 2px;
}

/* Widget decoration: numbered badge after each match */
.match-badge {
  margin-inline-start: 0.25rem;
  padding: 0.1rem 0.4rem;
  background: #1f2937;
  color: white;
  font-size: 0.75rem;
  border-radius: 0.25rem;
  vertical-align: middle;
  user-select: none;
}

Next steps