---
title: "Decorations in Tiptap"
description: "Add view-only highlights, search results, annotations, and widgets to your Tiptap editor with the Decorations API. Learn more in the docs!"
canonical_url: "https://tiptap.dev/docs/editor/core-concepts/decorations"
---

# Decorations in Tiptap

Add view-only highlights, search results, annotations, and widgets to your Tiptap editor with the Decorations API. Learn more in the docs!

Decorations change how content looks inside the editor. They do not change the document itself. They are not included when you save the editor as JSON or HTML.

Extensions add decorations with `addDecorations()`. Use the `Decoration` class to create them. Tiptap takes care of adding them to the editor.

> **Interactive demo:** [Decorations](https://embed.tiptap.dev/preview/Examples/Decorations?inline=false\&hideSource=false)

## When to use decorations vs. node views

Reach for decorations when the marker is purely visual and lives outside the document. Reach for a [node view](https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views.md) when the content is part of the document itself.

> **Decorations vs. node views:**
>
> Use **decorations** for highlights, search results, comments, annotations, and temporary UI
> markers that don't change the document. Use **[node
> views](https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views.md)** when you need persisted custom document
> content, editable custom blocks, or complex embedded UI that owns document structure.

## The three kinds of decoration

There are three kinds of decoration, all created through the `Decoration` class:

- **node** adds attributes, such as a CSS class, to one node.
- **inline** styles a range of text or other inline content.
- **widget** adds a DOM element or framework component at one document position.

## Declaring decorations

Extensions declare decorations through the `addDecorations()` lifecycle hook, which returns a `DecorationSpec` (or `null` for none). Build the decorations themselves with the `Decoration` class imported from `@tiptap/core`.

```ts
import { Decoration, Extension } from '@tiptap/core'

const MyExtension = Extension.create({
  name: 'myExtension',

  addDecorations() {
    return {
      create: ({ editor, state, view }) => [Decoration.Inline(1, 5, { class: 'highlight' })],
    }
  },
})
```

Like other extension hooks, `addDecorations()` is bound to `this` (`{ name, options, storage, editor, type, parent }`), so you can read options and storage while declaring decorations.

> **Read state from the state argument, not editor.state:**
>
> Inside `create()` and `createInRange()`, always read the document from the `state` argument, not
> `editor.state`. During a transaction, the editor's view state has not been updated yet, so
> `editor.state` points at the pre-transaction document. The `state` argument is the correct state
> being built.

### The `Decoration` class

The `Decoration` class has static methods that return decoration instances describing what Tiptap should show. Import it from `@tiptap/core`:

```ts
import { Decoration } from '@tiptap/core'

// Decorate a single node (attrs go on the node's DOM wrapper)
Decoration.Node(from, to, attrs, spec)

// Decorate a range of inline content (wraps it in a styled span)
Decoration.Inline(from, to, attrs, spec)

// Insert a widget (a DOM node) at a position; `key` is REQUIRED
Decoration.Widget(pos, render, options)
```

`attrs` accepts `class`, `style`, `nodeName`, and any other HTML attribute (ProseMirror's `DecorationAttrs`).

### Node decorations

A node decoration applies attributes to the DOM wrapper of a single node. This is the cheapest way to, for example, outline every heading with a class:

```ts
addDecorations() {
  return {
    create: ({ state }) => {
      const decorations = []
      state.doc.descendants((node, pos) => {
        if (node.type.name === 'heading') {
          decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
        }
      })
      return decorations
    },
  }
}
```

### Inline decorations

An inline decoration wraps a range of inline content in a styled span. This is ideal for highlights and search results:

```ts
Decoration.Inline(matchFrom, matchTo, { class: 'is-match' })
```

### Widget decorations

A widget decoration inserts a DOM node at a single position. Unlike node and inline decorations, widgets need a `render` callback and a **required, stable `key`**:

```ts
Decoration.Widget(
  pos,
  (view, getPos) => {
    const el = document.createElement('span')
    el.textContent = '★'
    return el
  },
  { key: `marker-${pos}`, side: -1 },
)
```

`side` controls which side of the position the widget belongs to. See ProseMirror's [`Decoration.widget`](https://prosemirror.net/docs/ref/#view.Decoration%5Ewidget). Every widget also needs a `key`.

## Widget keys

Every widget decoration requires a stable, position-independent `key`. This is the most common source of bugs when working with widgets.

> **Choose stable, globally unique keys:**
>
> ProseMirror reuses a widget's DOM across redraws **only when the key matches** the previous render. Without
> a stable key the widget is destroyed and recreated on every update, causing flicker and lost component
> state. Keys must also be **globally unique** across all widget decorations in the editor. Duplicate keys
> cause ProseMirror to misplace the widget DOM and crash.
>
> Tiptap logs a development warning when it detects two widget decorations with the same key in a single
> build, naming the offending extension:
>
> `[tiptap warn]: Duplicate widget decoration key "<key>" in extension "<name>". Widget decoration keys must
>     be globally unique…`
>
> The warning helps you find the problem. You must still make every key unique. Otherwise,
> ProseMirror misplaces the widget DOM.

**Good keys** use an ID that belongs to the item: `comment-${id}`, `paragraph-${node.attrs.id}`, `suggestion-${id}`.

**Bad keys** are unstable or position-dependent: a loop or paragraph index, a document position (`marker-${from}`), or any position-derived value.

If you need two widgets for one entity, suffix them: `comment-${id}-start` and `comment-${id}-end`.

> **Exception for stateless widgets:**
>
> For stateless widgets in demos and simple examples (such as a static marker), index- or
> position-based keys are acceptable. Widgets that hold state must use a stable item ID instead.

## Default behavior

- On editor init, every extension's `create` runs once to build the initial decorations.
- When the document changes, Tiptap builds the decorations again by default.
- When only the selection changes, Tiptap keeps the current decorations and updates their positions.

## Performance

Decorations use the `document` update strategy by default. This builds them again after every document change. For a large document, you can choose a different strategy to reduce the amount of work.

### Use `shouldUpdate` to skip updates

Return `false` from `shouldUpdate` when an edit cannot affect your decorations. Tiptap will keep them and update their positions instead of building them again.

```ts
addDecorations() {
  return {
    create: ({ state }) => buildHeadingOutline(state),
    shouldUpdate: ({ tr, oldState, newState }) => {
      // Only rebuild when the number of headings changed
      return countHeadings(oldState.doc) !== countHeadings(newState.doc)
    },
  }
}
```

By default, `shouldUpdate` runs after every document change.

### Use `changedRanges` for edited blocks

With `update: 'changedRanges'` and a `createInRange` callback, on a document change the manager:

1. Updates the positions of the current decorations.
2. Computes the changed range(s) of the transaction and expands them to the **enclosing top-level block boundaries**, so a match that overlaps the raw edit (for example typing in the middle of a word) is still fully contained.
3. Removes the now-stale decorations anchored in those blocks and rebuilds **only those blocks** by calling `createInRange({ state, from, to })`.

During normal editing, Tiptap does not call `create`. It only calls `create` when the editor starts or when you call [`updateDecorations()`](#build-decorations-again). This avoids scanning the full document after every key press.

`createInRange` receives a **block-aligned** `from`/`to` and must return only decorations within that range. It typically shares a scan helper with `create`:

```ts
addDecorations() {
  const scan = (editor, state, from, to) => {
    const decorations = []
    state.doc.nodesBetween(from, to, (node, pos) => {
      // …build decorations for nodes/text within [from, to]…
    })
    return decorations
  }

  return {
    update: 'changedRanges',
    create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
    createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
  }
}
```

> **createInRange contract:**
>
> `createInRange` must return **only** decorations whose start position lies within `[from, to)`.
> `from` for inline and node decorations, `pos` for widget decorations. `to` is exclusive: it is
> the next block's start, and that block rebuilds it, so decorations anchored at `to` are ignored.
> The last block is the exception, because it owns the end of the document. Decorations anchored
> before `from` belong to a neighbouring block that the manager did not remove, so returning them
> leaks duplicate decorations that are never cleaned up. If a decoration depends on content outside
> the supplied range, use the `document` strategy instead.

TypeScript enforces the pairing: `update: 'changedRanges'` requires `createInRange`, while the other strategies do not accept it.

> **Incremental mode is only correct for block-local decorations:**
>
> Incremental mode is **only correct when each decoration depends solely on the content within its own
> block or range.** Tiptap only scans edited blocks again. It does not check decorations in other blocks.
>
> **Safe (local):** "highlight every occurrence of a word", "outline every heading", "underline misspelled
> words". Each decoration depends only on its own block.
>
> **Not safe (depends on the whole document):** use the default `document` strategy and `create`, or force a
> full rebuild with `updateDecorations()`:
>
> - Ordinal/counting logic: "highlight only the first match", "color every 3rd occurrence".
> - Cross-document relationships: "mark duplicate words across the document".
> - Structural relations: "the first heading gets a special class".
> - Anything depending on the selection or external state. Changed-range updates react to *content* changes only;
>   trigger `updateDecorations()` on selection or state changes instead.

### Use `manual` for external state

Use `update: 'manual'` when document transactions should only map existing decorations to their new positions. Manual decorations are rebuilt only during initialization and when you call `updateDecorations()`.

Use the manual strategy when decorations depend on a value outside the document, such as a search query or filter. You cannot use it with `shouldUpdate` or `createInRange`.

## Build decorations again

Sometimes decorations depend on something outside the document, such as a search term or filter. Call `updateDecorations` when that value changes:

```ts
editor.commands.updateDecorations() // Update every extension.
editor.commands.updateDecorations('myExtension') // Update one extension.
```

`updateDecorations` always calls `create` for the full document. It ignores the update strategy and `shouldUpdate` for this update.

### Runtime parameters via storage

Store changing values in the extension's [`storage`](https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension.md#addstorage). Read them inside `create`, then call `updateDecorations` after changing them:

```ts
editor.storage.myExtension.term = 'foo'
editor.commands.updateDecorations('myExtension')
```

## Rendering framework components as widgets

Widget decorations can render React or Vue components. The component stays inside your editor's React or Vue app, so hooks, context, and `provide`/`inject` continue to work.

> **Interactive demo:** [DecorationComponents](https://embed.tiptap.dev/preview/Examples/DecorationComponents?inline=false\&hideSource=false)

### React: `ReactWidgetRenderer`

Import `ReactWidgetRenderer` from `@tiptap/react`. It returns a `WidgetDecoration` you return from `create` or `createInRange`, alongside `Decoration.Node` and `Decoration.Inline`. Your component additionally receives `editor` and `getPos` as props.

```tsx
import { Extension } from '@tiptap/core'
import { ReactWidgetRenderer } from '@tiptap/react'
import type { Editor } from '@tiptap/core'

function CommentMarker({
  editor,
  getPos,
  label,
}: {
  editor: Editor
  getPos: () => number | undefined
  label: string
}) {
  return <button onClick={() => console.log(getPos())}>{label}</button>
}

const Comments = Extension.create({
  name: 'comments',

  addDecorations() {
    return {
      create: ({ editor, state }) =>
        findComments(state.doc).map((c) =>
          ReactWidgetRenderer(CommentMarker, {
            editor,
            pos: c.pos,
            key: `comment-${c.id}`, // stable domain key → component state preserved
            props: { label: c.label },
          }),
        ),
    }
  },
})
```

A few things worth knowing:

- Tiptap sends new `props` when `create` or `createInRange` runs again. It reuses the renderer for the same key, so the component keeps its state.
- The cache is swept when the editor is destroyed.

### Vue: `VueWidgetRenderer`

Import `VueWidgetRenderer` from `@tiptap/vue-3` (or `@tiptap/vue-2`). It reuses Tiptap's `VueRenderer`, so the component shares the editor's app context (`provide`/`inject` work). The component receives `editor` and `getPos` in addition to your `props`.

```ts
import { Extension } from '@tiptap/core'
import { VueWidgetRenderer } from '@tiptap/vue-3'
import CommentMarker from './CommentMarker.vue'

const Comments = Extension.create({
  name: 'comments',

  addDecorations() {
    return {
      create: ({ editor, state }) =>
        findComments(state.doc).map((c) =>
          VueWidgetRenderer(CommentMarker, {
            editor,
            pos: c.pos,
            key: `comment-${c.id}`,
            props: { label: c.label },
          }),
        ),
    }
  },
})
```

> **Vue-specific notes:**
>
> The component **must render a single root element**. The `editor` is passed raw (with `markRaw`)
> on purpose. Do not wrap it in reactivity.

## Worked example: a search-term highlighter

This example uses all three kinds of decoration and incremental mode. It highlights every occurrence of a term (inline), renders a star marker before each match (widget), and outlines every heading (node). The term lives in storage and is changed at runtime via `updateDecorations`.

Each match and each heading depends only on its own block, so `update: 'changedRanges'` is safe here.

```ts
import { Decoration, Extension } from '@tiptap/core'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'

export interface HighlightOptions {
  term: string
}
export interface HighlightStorage {
  term: string
}

declare module '@tiptap/core' {
  interface Storage {
    highlight: HighlightStorage
  }
}

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

  addOptions: () => ({ term: 'tiptap' }),

  addStorage() {
    return { term: this.options.term }
  },

  addDecorations() {
    const scan = (editor: Editor, state: EditorState, from: number, to: number) => {
      const decorations: Decoration[] = []
      const term = editor.storage.highlight.term.trim().toLowerCase()

      state.doc.nodesBetween(from, to, (node, pos) => {
        if (node.type.name === 'heading') {
          decorations.push(Decoration.Node(pos, pos + node.nodeSize, { class: 'is-heading' }))
        }
        if (!term || !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
          decorations.push(Decoration.Inline(matchFrom, matchTo, { class: 'is-match' }))
          decorations.push(
            Decoration.Widget(
              matchFrom,
              () => {
                const el = document.createElement('span')
                el.textContent = '★'
                return el
              },
              { key: `marker-${matchFrom}`, side: -1 }, // stateless → position key OK
            ),
          )
          index = text.indexOf(term, index + term.length)
        }
      })
      return decorations
    }

    return {
      update: 'changedRanges', // each match/heading is block-local → safe
      create: ({ editor, state }) => scan(editor, state, 0, state.doc.content.size),
      createInRange: ({ editor, state, from, to }) => scan(editor, state, from, to),
    }
  },
})
```

Change the term by updating storage and rebuilding the decorations:

```ts
editor.storage.highlight.term = 'editor'
editor.commands.updateDecorations('highlight')
```

## Next steps

- Read the full [Decorations API reference](https://tiptap.dev/docs/editor/api/decorations.md) for every signature and option.
- Build a term highlighter with the [Vanilla JS tutorial](https://tiptap.dev/docs/guides/decorations-vanilla.md), [React tutorial](https://tiptap.dev/docs/guides/decorations-react.md), or [Vue 3 tutorial](https://tiptap.dev/docs/guides/decorations-vue.md).
- Learn how the [`addDecorations` hook](https://tiptap.dev/docs/editor/extensions/custom-extensions/create-new/extension.md#adddecorations) fits into the extension lifecycle.
- Compare with [node views](https://tiptap.dev/docs/editor/extensions/custom-extensions/node-views.md) when you need persisted, editable custom content.
