Decorate content
A decoration changes how a range of text looks without changing the document. It is never stored in the value and never emits patches. That makes it different from a schema decorator like strong and from an annotation like a link: both are marks saved with the text. The editor keeps each decoration on its text as the document changes, so a highlight stays on the same words when someone types before it.
Typical uses are search highlights, comments anchored to a passage, and the carets of other people editing the same document.
Choose an approach
Section titled “Choose an approach”The editor gives you two ways to decorate content:
rangeDecorations prop |
Decoration layers | |
|---|---|---|
| Who owns it | One PortableTextEditable |
Any number of components inside EditorProvider, each on its own |
| Identity | None | A stable id per decoration, matched across updates |
| Edits | An onMoved callback per decoration |
moved, content-changed, and lost events, batched per change |
| Live positions | Not available | Read with useDecorations |
| Read-only mode | Freezes | Keeps following remote edits |
Use the rangeDecorations prop for simple highlights that one editable owns. Customize editor rendering documents it. Use decoration layers when several features decorate the same editor, or when you need to track decorations through edits. The rest of this guide covers decoration layers.
Render a highlight
Section titled “Render a highlight”npm i @portabletext/plugin-decorationspnpm add @portabletext/plugin-decorationsyarn add @portabletext/plugin-decorationsA decoration has an id, a type (always 'range'), the range to decorate, and a render function that wraps the decorated text. defineDecoration types the object for you. Pass an array of decorations to DecorationsPlugin, inside the EditorProvider:
import { defineDecoration, EditorProvider, PortableTextEditable,} from '@portabletext/editor'import {DecorationsPlugin} from '@portabletext/plugin-decorations'
const decorations = [ defineDecoration({ id: 'search-match', type: 'range', range: { anchor: { path: [{_key: 'block1'}, 'children', {_key: 'span1'}], offset: 0, }, focus: {path: [{_key: 'block1'}, 'children', {_key: 'span1'}], offset: 5}, }, render: ({children}) => <mark>{children}</mark>, }),]
function MyEditor() { return ( <EditorProvider initialConfig={{schemaDefinition}}> <DecorationsPlugin decorations={decorations} /> <PortableTextEditable /> </EditorProvider> )}Each DecorationsPlugin is one layer. Add as many as you need: search highlights in one, spell-check underlines in another. Layers never touch each other, and unmounting one removes only its own decorations.
To change what a layer shows, pass a new array. The layer matches decorations to the previous array by id, not by position. A decoration whose id carries over keeps its current position unless you give it a different range. Every id must be unique within a layer. A duplicate throws.
React to edits
Section titled “React to edits”Comments need more than a highlight. When someone types before a commented passage, you want to save its new position. When they edit the passage, you want its new text. When they delete it, you want to mark the comment as orphaned. useDecorationLayer takes the same decorations as DecorationsPlugin, plus an on handler that receives these events:
import { defineDecoration, useEditor, type DecorationRenderProps, type EditorSelection,} from '@portabletext/editor'import {getSelectionText} from '@portabletext/editor/selectors'import {useDecorationLayer} from '@portabletext/plugin-decorations'import {useMemo} from 'react'
type Comment = { id: string range: NonNullable<EditorSelection> quote: string | undefined status: 'active' | 'orphaned'}
const renderCommentHighlight = (props: DecorationRenderProps) => ( <span className="comment-highlight">{props.children}</span>)
function CommentsLayer(props: { comments: Array<Comment> onCommentChange: (id: string, change: Partial<Comment>) => void}) { const editor = useEditor()
const decorations = useMemo( () => props.comments .filter((comment) => comment.status === 'active') .map((comment) => defineDecoration({ id: comment.id, type: 'range', range: comment.range, render: renderCommentHighlight, }), ), [props.comments], )
useDecorationLayer({ decorations, on: (events) => { for (const event of events) { if (event.origin !== 'local') { continue }
if (event.type === 'moved') { props.onCommentChange(event.decoration.id, {range: event.newRange}) }
if (event.type === 'content-changed') { const snapshot = editor.getSnapshot() const quote = getSelectionText({ ...snapshot, context: {...snapshot.context, selection: event.range}, }) props.onCommentChange(event.decoration.id, {quote}) }
if (event.type === 'lost') { props.onCommentChange(event.decoration.id, {status: 'orphaned'}) } } }, })
return null}Render CommentsLayer inside the EditorProvider. To create a comment, save the editor’s current selection as its range when the user clicks your “Add comment” button.
Each event carries the decoration as you supplied it:
movedmeans an edit shifted the decorated range.newRangeis where it is now.content-changedmeans an edit changed the text inside the range.rangeis the current position to read the text from.decoration.rangemay be out of date.lostmeans an edit deleted the decorated text. The layer drops the decoration.
The layer calls on once per change, after the editor has finished applying it, with every event that change produced. One change can produce more than one event for the same decoration. Typing inside a comment, for example, both moves its end and changes its text. Loop over the events and handle each one on its own, in order. on can be an inline function: the layer always calls the latest one.
Local and remote edits
Section titled “Local and remote edits”origin says where the edit came from. 'local' is an edit made in this editor. 'remote' is a change that arrived from a collaborator. In a collaborative app, every collaborator runs the same layer. A comment that moves because Alice typed reports 'local' in Alice’s editor and 'remote' in Bob’s. If both saved the move, your store would get it twice. Save only 'local' events, and each change is saved once, by the editor where it happened.
Read live positions
Section titled “Read live positions”useDecorationLayer returns a handle for the layer. Pass it to useDecorations to read where each decoration is right now, for example to line up a comments sidebar with the text:
import { useDecorations, type DecorationLayer,} from '@portabletext/plugin-decorations'
function CommentsSidebar(props: {layer: DecorationLayer}) { const positions = useDecorations(props.layer)
return ( <ul> {positions.map((position) => ( <li key={position.id}> {position.id} starts at offset {position.range.anchor.offset} </li> ))} </ul> )}In CommentsLayer, keep the handle and render the sidebar with it:
const layer = useDecorationLayer({ decorations, on: (events) => { // ... },})
return <CommentsSidebar layer={layer} />positions lists every live decoration with its id and current range, in the order of your decorations array. Lost and removed decorations are not in it. The array is empty until the layer has registered with the editor. It updates after each change and keeps the same reference between changes, so it is safe in a dependency array.
useDecorations re-renders its component on every edit that moves a decoration. Call it from a small component like CommentsSidebar, not from a large parent that would re-render with it.
Restore lost decorations
Section titled “Restore lost decorations”Undo brings back deleted text, but it does not bring back a lost decoration. Once a decoration is lost, the layer stops tracking it. Supplying the same id again with the range you supplied before, or with the range where it was lost, does nothing. To restore it, supply it with a different range, or remove its id from decorations and add it back in a later update.
The comments example above already removes an orphaned comment, because it only renders active ones. To restore a comment after undo, keep its last range and quote when it is orphaned. After each change to the editor value, read the text at that range. When the text matches the saved quote again, set the comment back to 'active'. It returns to decorations and the layer tracks it again.
Draw carets and markers
Section titled “Draw carets and markers”Some decorations draw something that is not text: a caret for another person’s cursor, or a marker at the start of a comment. Wrap that chrome in DecorationWidget. It renders a zero-width inline element that the caret can’t enter, so the chrome doesn’t change the layout of the text or get in the way of typing.
A presence caret decorates a collapsed range at the other person’s cursor:
import { defineDecoration, type DecorationRenderProps, type EditorSelection,} from '@portabletext/editor'import { DecorationsPlugin, DecorationWidget,} from '@portabletext/plugin-decorations'import {useMemo} from 'react'
type Peer = { id: string name: string color: string selection: EditorSelection}
function PresenceCaret( props: DecorationRenderProps & {color: string; name: string},) { return ( <> <DecorationWidget style={{borderLeft: `2px solid ${props.color}`, marginLeft: -1}} > <span title={props.name} style={{ position: 'absolute', top: -5, left: -1, width: 6, height: 6, borderRadius: '50%', transform: 'translateX(-50%)', backgroundColor: props.color, pointerEvents: 'auto', }} /> </DecorationWidget> {props.children} </> )}
function PresenceLayer(props: {peers: Array<Peer>}) { const decorations = useMemo( () => props.peers.flatMap((peer) => peer.selection ? [ defineDecoration({ id: peer.id, type: 'range', range: { anchor: peer.selection.focus, focus: peer.selection.focus, }, render: (renderProps) => ( <PresenceCaret {...renderProps} color={peer.color} name={peer.name} /> ), }), ] : [], ), [props.peers], )
return <DecorationsPlugin decorations={decorations} />}Follow these rules for widget chrome:
- Keep the decorated
childrenoutside the widget, as a sibling. The widget’s ownchildrenare its chrome, never document text. - Put chrome inside a
DecorationWidget, or draw it with CSS generated content (::beforeand::after). Bare text or editable elements next to the decorated text throw the caret off: arrow keys jump or stick around the decoration. - Render inline elements only.
DecorationWidgetis always aspan, and block elements inside a paragraph break it. - Clicks pass through the widget to the text underneath. To make part of the chrome interactive, give it
pointerEvents: 'auto', like the dot in the example, which shows the name on hover.
How decorations render
Section titled “How decorations render”render is called as a plain function, not as a component, so it can’t use hooks. When you need hooks, return a component that uses them, like PresenceCaret above.
One decoration can render more than once. The editor splits the range into segments where formatting changes and where it overlaps other decorations, and calls render for each segment. To draw something once per decoration, such as a comment badge, check isFirst (the segment with the start of the range) or isLast (the segment with the end):
const renderComment = (props: DecorationRenderProps) => ( <span className="comment-highlight"> {props.isFirst ? ( <DecorationWidget> <span className="comment-badge" /> </DecorationWidget> ) : null} {props.children} </span>)A collapsed range has one segment, which is both isFirst and isLast.
Where decorations overlap, their wrappers nest. A layer registered earlier wraps a layer registered later. For sibling plugins, that is the one that comes first. Within one layer, earlier entries in the array wrap later ones. The rangeDecorations prop always renders outermost.
Keep render references stable when you can. A new render function counts as a change and re-renders the decorated text. Define render at module level, like renderCommentHighlight, or build decorations inside useMemo so they only change when your data does.
Use layers outside React
Section titled “Use layers outside React”For code that holds an Editor but doesn’t live in a component, createDecorationLayer creates the same layer useDecorationLayer manages. You own its lifecycle, so call unregister() when you are done:
import type {Decoration, Editor} from '@portabletext/editor'import {createDecorationLayer} from '@portabletext/plugin-decorations'
function trackSearchMatches(editor: Editor, matches: Array<Decoration>) { const layer = createDecorationLayer(editor, { decorations: matches, on: (events) => { for (const event of events) { if (event.type === 'lost') { console.log(`Match ${event.decoration.id} was deleted`) } } }, })
return { update: (nextMatches: Array<Decoration>) => layer.update(nextMatches), getPositions: () => layer.current, stop: () => layer.unregister(), }}update replaces the whole set and matches decorations by id, the same way a new decorations array does. current holds the live positions that useDecorations reads.
The plugin builds on editor.registerDecorations, the low-level primitive in @portabletext/editor. It reports every single editor operation through an onMapped callback, while the operation is still being applied, and leaves batching to you. Most apps should use the plugin.