Annotations attach durable ranges to editor text and publish them to React rendering, sidebars, and widgets.
Use annotations when a range has identity beyond a single render pass: review comments, issue markers, tracked external diagnostics, resolved threads, or anchored suggestions.
Use decorations for transient paint such as search matches. Use widgets for UI that hangs off a node, selection, or annotation.
Annotation Shape
type SlateAnnotationAnchor = {
resolve(): Range | null
unref?(): Range | null
}
type SlateAnnotation<TData, TProjection> = {
anchor: SlateAnnotationAnchor
data?: TData
id: string
projection?: TProjection
}type SlateAnnotationAnchor = {
resolve(): Range | null
unref?(): Range | null
}
type SlateAnnotation<TData, TProjection> = {
anchor: SlateAnnotationAnchor
data?: TData
id: string
projection?: TProjection
}anchor resolves the current range. A local Bookmark satisfies this contract.
Adapters can use the same contract for service-owned anchors, remote positions,
or document-embedded ids.
data is application metadata. It is returned by useSlateAnnotation and
useSlateAnnotations.
projection is the small render-facing payload copied into text projection
slices. Put only fields that affect inline paint here.
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
projection: {
status: comment.status,
tone: comment.tone,
},
}))const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
projection: {
status: comment.status,
tone: comment.tone,
},
}))Local Bookmarks
Use a bookmark when the anchor belongs to the local editor runtime.
const anchor = editor.read((state) =>
state.ranges.bookmark({
anchor: { path: [0, 0], offset: 3 },
focus: { path: [0, 0], offset: 18 },
})
)
const annotationStore = useSlateAnnotationStore(editor, [
{
anchor,
data: { label: 'Comment 1' },
id: 'comment-1',
projection: { tone: 'review' },
},
])const anchor = editor.read((state) =>
state.ranges.bookmark({
anchor: { path: [0, 0], offset: 3 },
focus: { path: [0, 0], offset: 18 },
})
)
const annotationStore = useSlateAnnotationStore(editor, [
{
anchor,
data: { label: 'Comment 1' },
id: 'comment-1',
projection: { tone: 'review' },
},
])When annotations come from React state, use the projector form so the store refreshes from explicit dependencies.
const annotationStore = useSlateAnnotationStore(editor, {
deps: [comments],
project: () =>
comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
projection: { tone: comment.tone },
})),
})const annotationStore = useSlateAnnotationStore(editor, {
deps: [comments],
project: () =>
comments.map((comment) => ({
anchor: comment.anchor,
data: comment,
id: comment.id,
projection: { tone: comment.tone },
})),
})Pass the store to Slate when editor-local annotation UI lives under that
provider. useSlateAnnotations() and useSlateAnnotation(id) read that store
by default.
<Slate annotationStore={annotationStore} editor={editor}>
<Editable renderSegment={renderCommentSegment} />
<CommentsSidebar />
</Slate>
function CommentsSidebar() {
const snapshot = useSlateAnnotations()
return snapshot.allIds.map((id) => {
const comment = snapshot.byId.get(id)
return <CommentThread key={id} comment={comment} />
})
}<Slate annotationStore={annotationStore} editor={editor}>
<Editable renderSegment={renderCommentSegment} />
<CommentsSidebar />
</Slate>
function CommentsSidebar() {
const snapshot = useSlateAnnotations()
return snapshot.allIds.map((id) => {
const comment = snapshot.byId.get(id)
return <CommentThread key={id} comment={comment} />
})
}Dispose bookmarks when the app removes the annotation.
anchor.unref()anchor.unref()External Comment Stores
Comment bodies, permissions, resolved state, and audit events belong to the app or sync service. The Slate document value owns document content. Use Document State for document metadata and settings that should persist with the document.
const comments = useCommentChannel()
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: {
body: comment.body,
label: comment.label,
status: comment.status,
},
id: comment.id,
projection: {
status: comment.status,
tone: comment.tone,
},
}))const comments = useCommentChannel()
const annotations = comments.map((comment) => ({
anchor: comment.anchor,
data: {
body: comment.body,
label: comment.label,
status: comment.status,
},
id: comment.id,
projection: {
status: comment.status,
tone: comment.tone,
},
}))When an external store knows which comments changed, refresh those ids.
annotationStore.refresh({
ids: [threadId],
reason: 'annotation',
})annotationStore.refresh({
ids: [threadId],
reason: 'annotation',
})Refresh semantics:
- omit
idsfor a full refresh - pass an empty array for a no-op
- pass ids to re-resolve only those annotations
Comment Channels
Use separate channels for the document and the comments.
// Writer lane: document channel.
writerEditor.update((tx) => {
tx.text.insert('hello', { at })
})
// Reviewer lane: annotation channel.
commentsMap.set(threadId, {
anchor,
body,
status: 'open',
})
annotationStore.refresh({ ids: [threadId], reason: 'annotation' })// Writer lane: document channel.
writerEditor.update((tx) => {
tx.text.insert('hello', { at })
})
// Reviewer lane: annotation channel.
commentsMap.set(threadId, {
anchor,
body,
status: 'open',
})
annotationStore.refresh({ ids: [threadId], reason: 'annotation' })A read-only reviewer can select text, create a comment anchor, and update a thread without document-write permission. The adapter resolves the anchor against the current document snapshot for rendering.
The comment-mode example renders this as two panes:
- edit mode on the left, editable document channel
- comment mode on the right, read-only document with writable comments
- shared external comment state
- same resolved anchors rendered in both panes
The comment-mode controls do not call editor.update or mutate the
document.
Widgets
Widgets are app-owned UI descriptors anchored to a node, selection, or annotation. Use them for floating buttons, comment popovers, review toolbars, or side-panel rows that need resolved visibility.
type SlateWidgetAnchor =
| { annotationId: string; type: 'annotation' }
| { runtimeId: RuntimeId; type: 'node' }
| { type: 'selection' }
type SlateWidget<TData> = {
anchor: SlateWidgetAnchor
data?: TData
id: string
}type SlateWidgetAnchor =
| { annotationId: string; type: 'annotation' }
| { runtimeId: RuntimeId; type: 'node' }
| { type: 'selection' }
type SlateWidget<TData> = {
anchor: SlateWidgetAnchor
data?: TData
id: string
}Create widget stores with the same explicit-dependency projector shape.
const widgetStore = useSlateWidgetStore(editor, {
annotationStore,
deps: [comments],
project: () =>
comments.map((comment) => ({
anchor: { annotationId: comment.id, type: 'annotation' },
data: { label: comment.label },
id: `comment-widget:${comment.id}`,
})),
})const widgetStore = useSlateWidgetStore(editor, {
annotationStore,
deps: [comments],
project: () =>
comments.map((comment) => ({
anchor: { annotationId: comment.id, type: 'annotation' },
data: { label: comment.label },
id: `comment-widget:${comment.id}`,
})),
})Use useSlateWidgets(store) for panels that render every widget. Use
useSlateWidget(store, id) when one component watches one widget.
External Anchor Adapter
An external adapter can keep the document and comments in separate stores.
type ExternalAnnotationAnchor = {
resolve(): Range | null
unref(): Range | null
}
const anchor = externalAnnotationAdapter.anchorFromSlateRange(editor, range)
commentChannel.set(threadId, {
anchor,
body,
status: 'open',
})type ExternalAnnotationAnchor = {
resolve(): Range | null
unref(): Range | null
}
const anchor = externalAnnotationAdapter.anchorFromSlateRange(editor, range)
commentChannel.set(threadId, {
anchor,
body,
status: 'open',
})The adapter owns mapping, drift recovery, deletion policy, and permissions. Slate React owns projection once the adapter provides an anchor.
Document-Embedded Ids
Document-embedded ids are useful when the product wants comments to copy, paste, serialize, or travel with document content.
Use this as an adapter strategy, not as the default storage model for comment bodies or permissions. The document may store a lightweight id; the comment thread still belongs to the app or sync service.
Performance Rules
Keep annotation rows stable when their range and render payload do not change.
Keep body text, author data, permissions, and long thread metadata in data.
Keep inline paint fields in projection.
Use refresh({ ids }) for external comment updates when the changed ids are
known. Fall back to refresh() when the external source cannot provide ids.
Runtime subscribers wake only when their projected ranges or projection data
change. A comment body edit should wake annotation/sidebar subscribers without
repainting inline text when projection is unchanged.