> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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](https://www.sanity.io/docs/collaboration/users-and-identity) 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](https://collaboration-platform.sanity.dev/?path=/docs/introduction--docs).

## The five defaults

| Export | What it is |
| --- | --- |
| [CommentList](https://www.sanity.io/docs/collaboration/components) | Grouping, sorting, the empty state, and scroll targets |
| [CommentThread](https://www.sanity.io/docs/collaboration/components) | A thread: parent, collapsible replies, reply input |
| [Comment](https://www.sanity.io/docs/collaboration/components) | One comment: author, message, snapshot, reactions, actions |
| [CommentInput](https://www.sanity.io/docs/collaboration/components) | The Portable Text editor with the mention picker |
| [CommentBadge](https://www.sanity.io/docs/collaboration/components) | Icon plus count for a commented field |

Above all five is [CommentsPanel](https://www.sanity.io/docs/collaboration/components), 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](https://collaboration-platform.sanity.dev/?path=/docs/default-commentspanel--docs) 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:

```mermaid
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.

```tsx
// 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](https://www.sanity.io/docs/collaboration/show-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.

```tsx
<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](https://www.sanity.io/docs/collaboration/show-a-comment-preview).

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](https://www.sanity.io/docs/collaboration/navigation): 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](https://collaboration-platform.sanity.dev/?path=/docs/default-commentspanel--docs).

On a Sanity app, `SDKCommentsPanel` is this component with `useCommentThreads`, `useSDKThreadCallbacks`, and `createComment` already wired. See [Levels of abstraction in the comment stack](https://www.sanity.io/docs/collaboration/levels-of-abstraction).

## `CommentList`

See [CommentList](https://collaboration-platform.sanity.dev/?path=/docs/default-commentlist--docs) rendered in Storybook.

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

```tsx
<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](https://www.sanity.io/docs/collaboration/navigation).

## `CommentThread`

See [CommentThread](https://collaboration-platform.sanity.dev/?path=/docs/default-commentthread--docs) 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](https://www.sanity.io/docs/collaboration/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](https://collaboration-platform.sanity.dev/?path=/docs/default-comment--docs) 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.

```tsx
<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](https://collaboration-platform.sanity.dev/?path=/docs/primitives-commentheader--docs) 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](https://collaboration-platform.sanity.dev/?path=/docs/default-commentinput--docs) 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](https://www.sanity.io/docs/collaboration/translate-the-ui).

## `CommentBadge`

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

```tsx
<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](https://www.sanity.io/docs/collaboration/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](https://www.sanity.io/docs/collaboration/theme-the-components).

> [!WARNING]
> The primitives are internal
> Each one is exported and meant to be used, and tagged `@internal` to say its props may change in a minor release. The default components above them are where the API settles first, so reach for a primitive when a default stops fitting rather than by preference.

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:

```tsx
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](https://collaboration-platform.sanity.dev/?path=/docs/introduction--docs).

## 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:

```typescript
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](https://www.sanity.io/docs/collaboration/customize-comment-actions).

## Where to go next

- To rebuild a comment from the primitives: [Build a custom comment](https://www.sanity.io/docs/collaboration/build-a-custom-comment).
- To change how they look rather than how they are arranged: [Theme the components](https://www.sanity.io/docs/collaboration/theme-the-components).
- Why the components own no data: [Design principles of the comment components](https://www.sanity.io/docs/collaboration/principles).
- Every prop, state, and default: [Storybook](https://collaboration-platform.sanity.dev/?path=/docs/introduction--docs).



## Related changelog entries

Entries are listed newest first; each link points to the full entry as markdown. Follow one when you need to know what changed, when, or why — for example, to summarize recent updates, explain behavior that differs from older documentation, or check whether a fix has shipped.

- [@sanity/collaboration v0.2.0: Document-level comments, multi-document panels and DOM styling props](https://www.sanity.io/docs/changelog/320c84a9-99b0-4c42-b637-1c994bd83ffb.md) — October 29, 2026