Collaboration (Beta)

Comment components

What each comment component is responsible for, how they nest, and what changes when you compose the primitives yourself.

A comments panel is five components deep, and every piece it is assembled from is exported too. This page explains what each one is responsible for, how they nest, and what changes when you stop using the defaults and compose the primitives yourself.

Every component that renders a comment or a composer needs a CommentsProvider above it; CommentBadge does not. None of them calls the Sanity App SDK. Import the components, hooks, and primitives from @sanity/collaboration/ui, and SDKCommentsPanel and useSDKThreadCallbacks from @sanity/collaboration/sdk. Every prop, state, and default is in Storybook.

The five defaults

Export

What it is

CommentList

Grouping, sorting, the empty state, and scroll targets

CommentThread

A thread: parent, collapsible replies, reply input

Comment

One comment: author, message, snapshot, reactions, actions

CommentInput

The Portable Text editor with the mention picker

CommentBadge

Icon plus count for a commented field

Above all five is CommentsPanel, which arranges them into the panel this page describes. Start there and drop to the five when the arrangement stops fitting.

How they fit together

See CommentsPanel rendered in Storybook.

CommentsProvider holds what is the same for every comment on the surface. CommentList groups and sorts, and hands each thread to a function you write, which is where the callbacks are wired. CommentThread owns the thread, and Comment owns itself.

Only CommentList and CommentThread are stacked for you. CommentInput and CommentBadge are placed wherever your layout wants them.

Defaults first, primitives when they stop fitting

Each level of the diagram renders the same as the level that contains it:

This diagram could not be rendered.

              flowchart TB
  panel["CommentsPanel"] --> list["CommentList"]
  list --> yours["A thread layout of your own"]
  card["CommentThread"] --> comment["Comment"]
  yours --> card
  comment --> primitives["CommentHeader · CommentMessageContent · CommentReactions · CommentActionBar · …"]
            

When the default composition does not fit, drop one level rather than reaching for a flag. CommentsPanel becomes a CommentList with a header and a composer of your own, CommentList takes a children(thread) function, a thread layout of your own renders Comment directly, and a comment of your own is assembled from the same primitives Comment is built from.

Dropping a level keeps the skeletons, the accessible names, the reaction grouping, and the action list. Only the arrangement becomes yours.

A callback is a control

An action renders only when its callback is present, and is disabled when the matching capability resolves to false.

// No delete action at all.
<Comment comment={comment} />

// A delete action, grayed out if this user may not delete it.
<Comment comment={comment} onDelete={remove} />

Pass no callbacks and you get a read-only rendering: the same component, with nothing to call. That is how a comment preview is built.

So there is no showDeleteButton prop. Whether an action exists is the same question as whether you passed a handler for it.

CommentsPanel

The five defaults arranged: a status filter, the threads under it, and an Add a new comment thread action under each field heading. It renders nothing they do not, so anything it does can be done a level down.

<CommentsPanel
  threads={threads}
  compose={{fieldPath: 'title'}}
  threadProps={(thread) => ({onReply: (message) => reply(thread, message)})}
  onThreadCreate={({fieldPath, documentId}, message) =>
    create(fieldPath, message, documentId)
  }
/>

threadProps is where the per-thread writes go, which is why the panel takes no reply, edit, delete, or reaction callback of its own. On a Sanity app, useSDKThreadCallbacks(handle) is passed straight in, and anything you return after it wins. onCopyLink belongs there too: only your app knows what its URLs look like. The one write the panel does own is onThreadCreate, for the composers; omit it and they go. A read-only panel is that plus every capability off. See Make the panel read-only.

Every composer writes to a target: a field, and the document when the threads say which one. Each field heading carries an Add a new comment thread action that opens a composer there, and compose adds a composer at the top of the list, which is how the first thread on a field gets started. A target with fieldPath: '' is the document as a whole. When the threads come from several documents, each group is one field of one document, and a new thread's target carries the group's documentId.

It takes threads of both statuses and filters them itself, because a link to a resolved comment then flips the filter rather than coming back empty.

Selection comes from the surrounding CommentsProvider, in either of its navigation modes: clicking a card selects it, and a deep link the host passes as activeComment lands on it. It never reads window.location.

The compose options, unsent drafts and draftScope, the status filter props, and the multi-document warnings are in the CommentsPanel reference in Storybook.

On a Sanity app, SDKCommentsPanel is this component with useCommentThreads, useSDKThreadCallbacks, and createComment already wired. See Levels of abstraction in the comment stack.

CommentList

See CommentList rendered in Storybook.

It takes threads and a function, and forwards no callbacks of its own: actions are wired once, on CommentThread.

<CommentList threads={threads}>
  {(thread) => <CommentThread key={thread.threadId} thread={thread} />}
</CommentList>

By default it groups by fieldPath with the raw path as the heading, and props change the heading, flatten the grouping, or add an action under each heading (renderFieldAction). Threads are always ordered newest activity first, and groups by their newest thread.

A group is one field of one document, by parentComment.documentId, so a list can hold threads from several documents with no two documents' title running together. There is still one level of groups:

  • Threads on one document: the heading is the field path.
  • Threads on several documents: the heading is the document and the field, as in Launch week recap · title.
  • A comment on the document itself (fieldPath is ''): the document alone when the headings name documents, and Document otherwise.

Threads with no documentId don't count toward "several documents". documentTitle(group) says what to call a group's document; it gets the group rather than the id, and returns a node, so it can be a component that looks the title up. renderFieldHeading and renderFieldAction get the group's documentId beside its fieldPath, each group carries data-document-id, and scrollToField({fieldPath, documentId}) picks one document's group.

renderFieldHeading(group, {heading}) also gets heading, the built-in heading, so you can wrap it, say with a button that opens the document, instead of rebuilding it. Return null to drop the heading and undefined to keep the built-in one.

CommentList is also what reports that the data settled, which is what makes onActiveCommentNotFound fire. A surface with no list on screen never reports it. See Comment navigation and deep links.

CommentThread

See CommentThread rendered in Storybook.

One card holds a thread. This is the unit of the feature: a panel renders many, a badge popover renders one.

Its callbacks are comment-first, because a thread has more than one comment in it, so onEdit, onDelete, and onReactionToggle all take the comment they happened on. onReply and onStatusChange are the thread's own, so they take no comment. See Comment data flow.

Three behaviors are worth knowing:

  • Replies collapse. Past three, the rest go behind a Show N more control. A prop moves the line or turns it off.
  • The reply composer is conditional. By default it mounts once the thread is selected. replyInput overrides that: 'always' keeps it open, and 'onFocus' mounts it collapsed to a single line. There is no "never": omitting onReply is what drops it, since a thread with nothing to reply to has no composer to show.
  • Selection is automatic. Leave selected unset and the card selects itself whenever it holds the active comment, parent or reply. The surface holds one address, so only one card is ever selected. A card is a tab stop only where a click can write that address: under a read-only one, there is nothing a selection could do.

Comment

See Comment rendered in Storybook.

One comment, and the smallest thing worth rendering on its own. An activity feed or a mentions page is a list of these rather than of cards.

<Comment comment={comment} onEdit={(message) => update(comment.id, message)} />

variant="reply" leaves out the quoted snapshot, which the parent already shows, and props clamp the message or drop the reaction row.

The header is its own composition:

See CommentHeader rendered in Storybook.

CommentHeader fixes the avatar size and the gap, and gives you the right-hand side as children. Anything more than that is a header of your own, built from the same primitives.

CommentInput

See CommentInput rendered in Storybook.

The message editor, used standalone for a new thread, and internally by CommentThread for replies and by Comment for inline edits.

onSubmit may return a promise, and CommentInput keeps its submit disabled while it is pending, so an SDK action can be returned as-is. The editor stays editable, since what is typed next is the next comment. Left uncontrolled, it clears as the submit starts and puts the draft back if it fails and nothing new was typed.

It has no exported internals, because each of them needs an editor context that CommentInput already sets up.

Wording

The text a component writes for itself is overridable. The provider's labels sets it for the whole surface at once. Comment, CommentInput, CommentThread, and CommentsPanel each take a partial labels prop that wins over it, and a composer's placeholder and submitLabel props win over both for that one composer. See Translate the UI text.

CommentBadge

Icon plus count for a field, a row, or anything else that has comments on it. It renders a button.

<CommentBadge count={count} onClick={openPanel} />

At a count of zero it renders nothing by default, and a prop turns it into an "Add comment" affordance instead. The count is yours to compute, because there is no count-only read. See Show comment counts.

What a primitive takes

Comment is a composition, and every piece of it is exported. Reach for those pieces when you need to restructure a comment: move the name under the avatar, put the timestamp last, give the actions a permanent home instead of revealing them on hover. Changing how a comment looks needs none of them, and is covered in Theme the components.

The primitives are internal

Three differences from a headless component library are worth planning around:

  • They are styled. @sanity/ui is a required peer dependency, so each primitive is built from Card, Flex, Text, and Button rather than shipping unstyled reimplementations of them. You are composing finished pieces, not wiring behavior into your own markup.
  • They are flat named exports. CommentHeader, not Comment.Header, so importing UserAvatar does not pull CommentInput and @portabletext/editor in with it.
  • There is no asChild. Each renders its own element. To merge with a component from your design system, wrap it or replace it, and most are thin enough that replacing one is a few lines.

They split into two groups by where they get a user from.

The pieces that show a user take the user. UserAvatar, UserName, MentionChip, and CommentHeader take a user, and a loading flag while it is still being fetched (author and authorLoading on CommentHeader), so they render anywhere a Sanity UI theme is, with or without a CommentsProvider. useCommentUser turns an author id into both:

const {user, loading} = useCommentUser(comment.authorId)

<CommentHeader
  author={user}
  authorLoading={loading}
  createdAt={comment.createdAt}
/>

MentionChip is the one with no loading state: a chip is a run of text, so an unresolved mention reads as its raw @id rather than reflowing the line. It takes both userId and user for that reason: the id is what the message carries and what the chip can always render; the user is the resolution, when you have one.

The rest read the provider. CommentMessageContent, CommentReactions, and AddReactionButton get users and reaction options from context. They only ever render inside a comment, and going through the provider is what keeps every card on a surface agreeing without you passing anything.

Timestamps sit in between: CommentTimestamp formats on its own, and defers to the provider's formatTime when there is one.

The full list of primitives, each with its props and states, is the Primitives group in Storybook.

What a card of your own takes on

Comment does both of these for you. A card of your own has to do them itself.

The action row and its reveal. Comment hides its action row until hover or focus. A card of your own owns its row, so it decides whether the row hides and how. The minimum is the reveal, written against the card's own styled component:

const ActionRow = styled(Flex)``

const CommentCard = styled(Card)`
  ${ActionRow} {
    opacity: 0;
  }

  &:hover ${ActionRow}, &:focus-within ${ActionRow} {
    opacity: 1;
  }
`

To keep the built-in Comment and show its row all the time, pass actions="always" instead.

Comment goes one step further and floats the row over the header's right end (position: absolute, against the header, which is positioned for it), so a row that is hidden most of the time does not hold its width the whole time. In a narrow column that width came out of the name. Left in the row, as in the CommentCard reveal, your actions push the name and time aside instead, which is the simpler choice when they are always visible.

The body indent lines the message up under the name. Comment reads it from the theme, avatar size 0 (19px) plus the header's gap, so a card of your own does the same for whichever avatar size its header uses.

Actions are data

CommentActionBar takes an array and a number. The array is a list of actions, which commentActions builds for you, and the number is how many of them render as icon buttons before the rest collapse behind the ellipsis.

An action reports that the user asked for something. CommentActionBar does not own the flow an entry starts: Edit flips your own state and renders CommentInput, and Delete opens DeleteCommentDialog, whose confirm is what reaches the SDK.

AddReactionButton is not an action, because it opens its own popover rather than running a handler, so it composes beside CommentActionBar rather than inside it.

See Customize comment actions.

Where to go next

Was this page helpful?

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