Decorations in Tiptap
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.
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 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 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.
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:
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:
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:
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:
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. 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
createruns 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.
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:
- Updates the positions of the current decorations.
- 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.
- 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(). 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:
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:
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. Read them inside create, then call updateDecorations after changing them:
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.
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.
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
propswhencreateorcreateInRangeruns 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.
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.
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:
editor.storage.highlight.term = 'editor'
editor.commands.updateDecorations('highlight')Next steps
- Read the full Decorations API reference for every signature and option.
- Build a term highlighter with the Vanilla JS tutorial, React tutorial, or Vue 3 tutorial.
- Learn how the
addDecorationshook fits into the extension lifecycle. - Compare with node views when you need persisted, editable custom content.