Collaboration (Beta)

Inline comments

How an inline comment anchors to a run of Portable Text and keeps its highlight on the same words as the text changes.

An inline comment is anchored to a run of text inside a Portable Text field, rather than to the whole field. The editor draws it as a highlight under those words. The highlight stays on the same words while the text around them changes, and it disappears when the words are deleted. You can also start a new thread from a text selection. This page explains how the anchor makes that work.

@sanity/collaboration does the drawing, as one component you render beside the editable. The component needs a CommentsProvider above it, which SDKCommentsProvider supplies in a Sanity app:

import {
  defineSchema,
  EditorProvider,
  PortableTextEditable,
} from '@portabletext/editor'
import {
  SDKCommentsProvider,
  SDKPortableTextComments,
} from '@sanity/collaboration/sdk'
import type {DocumentHandle} from '@sanity/sdk'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'
import {Suspense} from 'react'

const schemaDefinition = defineSchema({
  decorators: [{name: 'strong'}, {name: 'em'}],
})

function BodyField({handle}: {handle: DocumentHandle}) {
  const {threads} = useCommentThreads({...handle, status: 'open'})

  return (
    <EditorProvider initialConfig={{schemaDefinition}}>
      <PortableTextEditable />
      <SDKPortableTextComments {...handle} path="body" threads={threads} />
    </EditorProvider>
  )
}

export function CommentableBody({handle}: {handle: DocumentHandle}) {
  return (
    // SDKCommentsProvider suspends until it knows who the viewer is.
    <Suspense fallback={<p>Resolving your identity…</p>}>
      <SDKCommentsProvider>
        <Suspense fallback={<p>Loading comments…</p>}>
          <BodyField handle={handle} />
        </Suspense>
      </SDKCommentsProvider>
    </Suspense>
  )
}

The component runs inside the editor's EditorProvider, beside the PortableTextEditable it decorates. For the wiring, step by step, see Comment on Portable Text.

An anchor is marked text, not offsets

An inline comment carries a selection alongside its fieldPath:

const comment = {
  fieldPath: 'body',
  selection: {
    type: 'text',
    value: [
      {
        _key: 'block-3',
        text: 'The quick \uF000brown fox\uF001 jumps over the lazy dog.',
      },
    ],
  },
  // ...
}

For each block the comment touches, the stored text is the block's whole plain text. Two private-use characters mark where the selection starts and ends: U+F000 and U+F001. The selection doesn't store character offsets, and it doesn't store the selected words on their own.

Storing the whole block's text is what lets the highlight survive edits. To find the words again, the marked text is diffed against the block's current text, and the markers move with the diff. You can insert a paragraph before the block, retype the sentence around the words, or bold half of them, and the comment still lands on the same words. The diff finds nothing only when the words are gone or have changed beyond recognition.

Create and move comments with an anchor

You never write a selection directly. To create or move a comment, you pass an anchor: a start point and an end point, each a block key and an offset into that block's text. The API resolves the anchor into the stored selection.

import {useCommentActions} from '@sanity/sdk-react/collaboration'

const actions = useCommentActions()

actions.createComment({
  ...handle,
  fieldPath: 'body',
  anchor: {
    type: 'portable-text',
    start: {_key: 'block-3', offset: 10},
    end: {_key: 'block-3', offset: 19},
    fieldValue: blocks, // the blocks the offsets count into
  },
  message, // the comment body as Portable Text blocks
})

fieldValue exists because the editor is usually ahead of the server. An author can select words they've only just typed, which the server hasn't seen yet. If the API resolved the anchor against its own copy of the document, the comment would land on the wrong words, or nowhere. Passing the blocks the anchor counts into makes the API resolve it against the text the author saw. Because it's part of the anchor rather than a separate option, a write without an anchor can't include it.

Re-anchoring

The diff runs only to place a comment the first time: when the comment list arrives, and again for any comment whose blocks hadn't loaded yet. After that, the comment is a range the editor tracks. The range follows edits as they happen, without another diff.

The stored anchor has to follow the edits too. Otherwise, the next reader (a reload or a colleague) diffs the old marked text against the new text, and the highlight drifts further as the text changes. So once typing stops, each moved comment's new anchor is written back with updateCommentAnchor.

This diagram could not be rendered.

              flowchart LR
  stored["Stored selection<br>(marked text)"] -->|"diffed once, to place it"| draw["Tracked range"]
  edit["An edit"] -->|"the editor moves the range"| draw
  draw -->|"where it has got to"| held["Held until the typing stops"]
  held --> range["New anchor + fieldValue"]
  range -->|"updateCommentAnchor"| stored
            

A comment that spans several blocks is drawn as one range, not one range per block. The range runs from where the comment starts in its first block to where it ends in its last. Because the commented text is continuous, the highlight has no gap even when the diff no longer recognizes a middle block's stored text. Both ends must be found, though. If either is missing, the comment isn't drawn, because a highlight on the wrong words is worse than none.

Two rules keep anchor writes correct.

Only from the editor that made the edit. Comments written against the published document and against the draft can both be on screen, because reads combine the document's variants. Re-anchoring a published comment from the draft's text would attach it to words it was never about. So a comment is only moved by an editor on the document it was written against. Likewise, when an edit arrives from another editor on the same document, that editor writes the new anchor. This one doesn't, so the anchor isn't written twice.

Only once, and only when something changed. A comment can move on every keystroke. Writing each move would cost a request, a store update, and a re-read of the comment for every letter typed, and the next letter would overwrite it anyway. Instead, the writes are batched: each comment that a run of typing touched is written once, with its final position. A batch is written when typing pauses. If typing never pauses, it's written after a maximum wait, and anything outstanding is written when the editor loses focus or closes. Nothing is written for an edit that leaves a range in place and its words unchanged.

When the commented words are deleted, the anchor is written as null and the comment becomes a field-level comment on the same field. The thread keeps its quoted snapshot, but it no longer has a highlight.

Props-driven and SDK-wired components

Like CommentsProvider and SDKCommentsProvider, inline comments come in a props-driven form and an SDK-wired one.

PortableTextComments takes the comments as props and reports every write to its own callbacks. The package's own stories and tests mount it, and so does a host with its own data layer. To turn off re-anchoring, leave out the onRangeChange callback. Do that only if something else keeps the stored anchors current.

SDKPortableTextComments takes the threads your app has already read, and makes its writes through the SDK with createComment and updateCommentAnchor. Most apps use this one.

Neither component syncs the editor's value to the document. That's the host's job, because an editor is usually already wired to save its value somewhere. Both components read the value from the editor, so they work however the value gets there.

Nested fields

A Portable Text array can hold a block that itself holds Portable Text, such as a callout with a content array. A comment there has the longer path, body[_key=="c1"].content, and it belongs to an editor mounted on that array with that path. An editor on body leaves it alone: the comments it draws are the ones whose fieldPath falls under its own, and a selection made inside a nested editor produces the nested field path when a comment is created from it.

What is not drawn

An editor draws no highlight for these comments:

  • A resolved thread. Its highlight disappears when the thread is resolved.
  • A comment whose words are no longer in the field.
  • A comment on the field itself, without a selection.
  • A reply. Replies are read only to find which thread holds the active comment.

Inline threads in the comments panel

The comments panel needs no changes. A thread started inline is an ordinary thread. Its contentSnapshot quotes the words it was about, so the panel's cards render it like any other thread. Clicking a highlight makes its thread the active comment through the CommentsProvider that the panel is under. The panel then scrolls to the thread and selects it. When you select the thread in the panel instead, its highlight switches to the active style. See Comment navigation and deep links.

Next steps

Continue with these pages to put inline comments to work in your app.

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme