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
- Review decoration types and update strategies in the core concepts guide.
- Read the full Decorations API reference for every signature and option.
- See the Vanilla JS tutorial if you want to build widgets by hand without a framework, or the Vue 3 tutorial for Vue.