Collaboration (Beta)

Show a comment preview

Show one comment in a popover from a badge or a highlighted range, and make a comments panel read-only.

A comment in a popover, shown from a badge or from a highlighted range in an editor. This guide builds one out of Comment, and covers how to make a panel read-only.

Prerequisites:

  • A CommentsProvider around the surface, and a thread to preview. See Get started.

Render the preview

There is no CommentPreview component, and there does not need to be one. A Comment with no callbacks renders no actions, showReactions={false} drops the reaction row, and maxLines clamps the message:

import {type CommentThreadData} from '@sanity/collaboration'
import {Comment} from '@sanity/collaboration/ui'
import {Stack, Text} from '@sanity/ui'
import {Popover} from '@sanity/ui/popover'

export function CommentPreview({
  thread,
  open,
  referenceElement,
}: {
  thread: CommentThreadData
  open: boolean
  referenceElement: HTMLElement | null
}) {
  return (
    <Popover
      open={open}
      referenceElement={referenceElement}
      content={
        <Stack gap={3} padding={2}>
          <Comment
            comment={thread.parentComment}
            variant="reply"
            showReactions={false}
            maxLines={3}
          />

          {thread.commentsCount > 1 && (
            <Text muted>
              {thread.commentsCount === 2
                ? '1 reply'
                : `${thread.commentsCount - 1} replies`}
            </Text>
          )}
        </Stack>
      }
    />
  )
}

Popover comes from @sanity/ui/popover. The root @sanity/ui export of it is a placeholder in Sanity UI v4, and fails to typecheck with JSX element type 'Popover' does not have any construct or call signatures. The preview goes in content, which renders inside the popover. The children prop is the element the popover anchors to, not its body.

The reply count is a line of your own, because a preview's copy belongs to the surface showing it. Translate it along with the rest of your UI.

The trigger is yours too. This badge keeps its own element in state with ref={setElement}, so the popover has something to anchor to, and opens the preview while the pointer is over it:

import type {CommentThreadData} from '@sanity/collaboration'
import {CommentBadge} from '@sanity/collaboration/ui'
import {useState} from 'react'

import {CommentPreview} from './CommentPreview'

export function FieldBadge({thread}: {thread: CommentThreadData}) {
  const [element, setElement] = useState<HTMLButtonElement | null>(null)
  const [hovered, setHovered] = useState(false)

  return (
    <>
      <CommentBadge
        count={thread.commentsCount}
        ref={setElement}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
      />
      <CommentPreview
        thread={thread}
        open={hovered}
        referenceElement={element}
      />
    </>
  )
}

variant="reply" leaves out the quoted snapshot, which the parent already shows. In a popover that is usually what you want: the preview opens from the words the comment is about, so quoting them again only takes up room. Pass showContentSnapshot={false} instead to say so explicitly.

Why passing no callbacks is enough

An action renders only when its callback is present, so passing none leaves Comment with nothing to render but the comment itself. There is no separate read-only rendering to keep in step with the real one. See Comment components.

Clamp the message

maxLines clamps the message to that many lines, which keeps a popover a predictable size whatever the comment says:

<Comment comment={thread.parentComment} maxLines={3} />

Make the panel read-only

For a panel where nothing should be editable, such as an audit view or an export preview, turn every capability off and leave out onThreadCreate:

import type {CommentThreadData} from '@sanity/collaboration'
import {CommentsPanel, READ_ONLY_CAPABILITIES} from '@sanity/collaboration/ui'

export function ReadOnlyPanel({threads}: {threads: CommentThreadData[]}) {
  return (
    <CommentsPanel
      threads={threads}
      threadProps={() => ({capabilities: () => READ_ONLY_CAPABILITIES})}
    />
  )
}

For the whole surface, pass capabilities={() => READ_ONLY_CAPABILITIES} to the provider instead. The composers and the reaction picker go; resolve, reopen, the menu entries and the reaction pills stay, disabled. Selecting a comment and copying its link still work.

SDKCommentsPanel always wires onThreadCreate, so for a read-only view with no add-thread buttons, use CommentsPanel with useCommentThreads from @sanity/sdk-react/collaboration.

For a standalone Comment, pass capabilities={READ_ONLY_CAPABILITIES}, the object itself rather than a function returning it. Or leave out its write callbacks, as CommentPreview does.

Next steps

Was this page helpful?

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