Highlight Search Terms with Decorations in Vue 3
In this tutorial, you will build a term highlighter for a Tiptap editor in Vue 3. 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 Vue 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 Vue components, you use VueWidgetRenderer from @tiptap/vue-3.
Set up the editor and search input
Start with a basic Vue component that renders a search input and the editor:
<!-- TiptapEditor.vue -->
<template>
<div>
<div class="highlight-toolbar">
<input
v-model="searchTerm"
aria-label="Search"
placeholder="Type a word to highlight..."
/>
</div>
<EditorContent :editor="editor" />
</div>
</template>
<script setup lang="ts">
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { onBeforeUnmount, ref } from 'vue'
import { Highlight } from './highlight'
import './styles.css'
// We store the search term in a ref so the input stays reactive
const searchTerm = ref('')
const editor = new Editor({
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>
`,
})
// Clean up the editor when the component unmounts
onBeforeUnmount(() => {
editor.destroy()
})
</script>The Highlight extension does not exist yet. We will create it next. The input is wired to a Vue ref 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.ts
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 Vue component later. Inside addDecorations, we read the term from storage.
Create the badge widget component
Before we add decorations, let us create the Vue component that the widget will render. It is a small numbered badge that appears after each match.
VueWidgetRenderer 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.vue -->
<template>
<span class="match-badge" contenteditable="false">
{{ matchNumber }}
</span>
</template>
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3'
// editor and getPos are passed automatically by VueWidgetRenderer.
// We declare them so Vue does not warn about unknown props,
// but we do not use them for a static badge.
defineProps<{
editor: Editor
getPos: () => number | undefined
matchNumber: number
}>()
</script>The contenteditable="false" attribute is important. Without it, the user could type inside the badge, which would confuse ProseMirror's editing logic.
Vue widget components must have a single root element
Vue requires every component to have exactly one root element. The badge uses a single <span>,
which is fine. If your widget needs multiple elements, wrap them in a container element.
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.ts
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 VueWidgetRenderer to render the MatchBadge component we created earlier. We need a counter to number matches across all text nodes:
// Add the imports at the top of highlight.ts:
import { VueWidgetRenderer } from '@tiptap/vue-3'
import MatchBadge from './MatchBadge.vue'
// 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 Vue badge after the match.
// VueWidgetRenderer takes the component and an options object.
// The component receives the props we pass plus editor and getPos.
matchNumber++
decorations.push(
VueWidgetRenderer(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
Vue 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.ts
import { Decoration, Extension } from '@tiptap/core'
import { VueWidgetRenderer } from '@tiptap/vue-3'
import MatchBadge from './MatchBadge.vue'
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 Vue badge after the match
matchNumber++
decorations.push(
VueWidgetRenderer(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:
<!-- TiptapEditor.vue -->
<template>
<div>
<div class="highlight-toolbar">
<input
v-model="searchTerm"
aria-label="Search"
placeholder="Type a word to highlight..."
/>
</div>
<EditorContent :editor="editor" />
</div>
</template>
<script setup lang="ts">
import StarterKit from '@tiptap/starter-kit'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { onBeforeUnmount, ref, watch } from 'vue'
import { Highlight } from './highlight'
import './styles.css'
const searchTerm = ref('')
const editor = new Editor({
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>
`,
})
// Watch the search term ref. When it changes, call our custom command
// which stores the term and rebuilds decorations in one step.
watch(searchTerm, (value) => {
editor.commands.setSearchTerm(value)
})
onBeforeUnmount(() => {
editor.destroy()
})
</script>The flow is: the user types, the Vue ref updates, the watcher calls setSearchTerm which 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 React tutorial for React.