---
title: "Highlight Search Terms with Decorations in Vue 3"
description: "Build a term highlighter for a Vue 3 Tiptap editor with inline, node, and widget decorations."
canonical_url: "https://tiptap.dev/docs/guides/decorations-vue"
---

# Highlight Search Terms with Decorations in Vue 3

Build a term highlighter for a Vue 3 Tiptap editor with inline, node, and widget decorations.

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:

```vue
<!-- 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:

```ts
// 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:

```vue
<!-- 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

```ts
// 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:

```ts
// 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:

```ts
// 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](https://tiptap.dev/docs/editor/core-concepts/decorations.md#widget-keys) in the core concepts guide.

## The complete extension

Here is the full extension with all three decoration types:

```ts
// 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:

```vue
<!-- 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:

```css
/* 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](https://tiptap.dev/docs/editor/core-concepts/decorations.md) in the core concepts guide.
- Read the full [Decorations API reference](https://tiptap.dev/docs/editor/api/decorations.md) for every signature and option.
- See the [Vanilla JS tutorial](https://tiptap.dev/docs/guides/decorations-vanilla.md) if you want to build widgets by hand without a framework, or the [React tutorial](https://tiptap.dev/docs/guides/decorations-react.md) for React.
