> 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).

# Build a custom comment

Build your own comment card from the primitives the default Comment uses, keeping its behavior and accessibility.

When the default `Comment` layout stops fitting, you rebuild the layout rather than configure it. This guide assembles a card that keeps the core behavior of `Comment` and rearranges two things: the emoji picker stays in the header instead of moving to the reaction row, and a "Mark as spam" entry joins the actions.

Prerequisites:

- A working comments panel, set up with [Get started](https://www.sanity.io/docs/collaboration/get-started). This guide uses the same dependencies and adds no others.
- The primitives a comment is composed from, and what a card of your own takes on. See [Comment components](https://www.sanity.io/docs/collaboration/components).

> [!WARNING]
> The primitives are internal
> The card below is built from exported components whose props may change in a minor release. See [What a primitive takes](https://www.sanity.io/docs/collaboration/components).

Every snippet on this page goes into one file, which opens with every import the snippets use. [Assemble the card](https://www.sanity.io/docs/collaboration/build-a-custom-comment) shows where each snippet goes:

**CustomComment.tsx**

```tsx
import {
  type CommentData,
  type CommentReactionShortName,
} from '@sanity/collaboration'
import {
  AddReactionButton,
  CommentActionBar,
  commentActions,
  CommentErrorNotice,
  CommentHeader,
  CommentInput,
  CommentMessageContent,
  CommentReactions,
  DeleteCommentDialog,
  useActiveComment,
  useCommentsContext,
  useCommentUser,
  useNavigableComment,
  UserAvatar,
  useSelectionProps,
} from '@sanity/collaboration/ui'
import {WarningOutlineIcon} from '@sanity/icons/WarningOutline'
import {useCommentActions} from '@sanity/sdk-react/collaboration'
import {Badge, Box, Card, type CardTone, Flex, useTheme_v2} from '@sanity/ui'
import {useState} from 'react'
import {styled} from 'styled-components'
```

## Declare the hover reveal

The card owns its action row, so it owns the reveal too. Give the row a styled component of its own and key the reveal off it:

```tsx
const ActionRow = styled(Flex)``

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

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

`:focus-within` keeps the row usable from the keyboard. Without it, a keyboard user tabs into buttons that are invisible.

## Read what the card needs

The card reads what it needs from four hooks, none of which fetches:

```tsx
const {resolveCapabilities} = useCommentsContext()
const {activeComment, setActiveComment} = useActiveComment()
const actions = useCommentActions()
const author = useCommentUser(comment.authorId)

const capabilities = resolveCapabilities(comment)
const selected = activeComment?.commentId === comment.id
```

`resolveCapabilities` is what keeps your card agreeing with the ones around it. It resolves each capability from any `capabilities` overrides you pass as its second argument, then the provider's policy, then the built-in defaults. You never compare `authorId` yourself.

## Resolve the author

A comment carries an `authorId`, and the primitives that show a user take the user. So the card is where the one becomes the other, and `useCommentUser` is that step, giving `{user, loading}` from the same lookup every other comment on the surface uses.

Two things follow. A list of users needs a component per row, because a hook cannot run inside `map`. The thread layout in [Render the card in a thread](https://www.sanity.io/docs/collaboration/build-a-custom-comment) uses this for its participant avatars:

```tsx
function ParticipantAvatar(props: {userId: string}) {
  const {user, loading} = useCommentUser(props.userId)

  return <UserAvatar user={user} loading={loading} />
}
```

And nothing else in the card has to be wired: the message, the pills, and the picker resolve their own users through the provider.

## Wire selection and deep links

Two hooks connect the card to deep links and to selection:

```tsx
const {ref} = useNavigableComment<HTMLDivElement>(comment.id)

const selectionProps = useSelectionProps(() => setActiveComment(comment.id))
```

`useNavigableComment` registers the comment with navigation, so a `?comment=` address scrolls to and focuses this element. `useSelectionProps` makes the card behave like a button: click, plus Enter and Space when the card itself has focus, so the buttons and the editor inside it keep their own keys.

Spread both onto the root:

```tsx
<CommentCard
  padding={3}
  radius={3}
  tone={cardTone({createError, selected})}
  {...selectionProps}
  ref={ref}
  tabIndex={selectionProps.tabIndex ?? -1}
>
```

The `tabIndex` line is the half most people miss. `useSelectionProps` makes a selectable card focusable already, so this only matters for a card that is not selectable, such as a reply in a layout where the thread is the selection. Focus cannot move to an element with no tab index, so without it a deep link to that reply scrolls to it and leaves the keyboard behind.

## Choose the card's tone

A module-level function picks the card's tone from its state:

```tsx
function cardTone(state: {createError: boolean; selected: boolean}): CardTone {
  if (state.createError) return 'critical'
  if (state.selected) return 'primary'
  return 'inherit'
}
```

A failed create outranks selection, because the error is the more urgent state. `Comment` itself marks selection with an outline rather than a tone; a tone is this card's choice, and either works for a card of your own. Two other states are opacity rather than tone, so they compose with it:

```tsx
const dimmed =
  comment.state?.type === 'createRetrying' || comment.status === 'resolved'
```

Apply it as the card's opacity:

```tsx
style={{opacity: dimmed ? 0.6 : undefined}}
```

## Compose the header

`CommentHeader` renders the author and the timestamp, then your children on the right, with no box around them. It takes the author and the dates rather than the comment, which is also what lets the same header serve an activity feed. So what hides on hover, and what does not, is this layout's decision:

```tsx
<CommentHeader
  author={author.user}
  authorLoading={author.loading}
  createdAt={comment.createdAt}
  lastEditedAt={comment.lastEditedAt}
>
  {flagged && (
    <Badge fontSize={0} tone="critical">
      Spam
    </Badge>
  )}

  {!editing && (
    <ActionRow align="center" gap={1}>
      {capabilities.canReact && (
        <AddReactionButton
          reactions={comment.reactions}
          onReactionToggle={toggleReaction}
        />
      )}

      <CommentActionBar actions={actionList} />
    </ActionRow>
  )}
</CommentHeader>
```

`AddReactionButton` sits in the header here rather than in the row of `CommentReactions`, and the spam `Badge` sits outside `ActionRow`, so it stays visible while the buttons fade.

## Build the action list

The default list, plus one of your own:

```tsx
const actionList = [
  ...commentActions({
    comment,
    capabilities,
    onStatusChange: (status) =>
      actions.setCommentStatus({commentId: comment.id, status}),
    onEdit: () => setEditing(true),
    onDelete: () => setDeletePending(true),
  }),
  {
    name: 'Mark as spam',
    icon: WarningOutlineIcon,
    action: () => setFlagged(true),
  },
]
```

Note what `onEdit` and `onDelete` do here: they flip local state. An action reports that the user asked for something, and the flow it starts is yours. See [Customize comment actions](https://www.sanity.io/docs/collaboration/customize-comment-actions).

## Render the body

The body lines up under the author name, so the indent is whatever your header's avatar and gap add up to. Read both from the theme rather than writing a number, so it stays in step with Sanity UI's avatar scale:

```tsx
const {avatar, space} = useTheme_v2()
const bodyIndent = avatar.sizes[0].size + space[2]
```

Pad the body by that indent, except while editing:

```tsx
<Box style={{paddingLeft: editing ? 0 : bodyIndent}}>
```

Editing drops the indent, because `CommentInput` wants the full width.

Inside, the message or the editor:

```tsx
{
  editing ? (
    <CommentInput
      // The user has just chosen Edit, so the caret belongs here.
      // oxlint-disable-next-line jsx-a11y/no-autofocus
      autoFocus
      defaultValue={comment.message}
      submitLabel="Update"
      onSubmit={(message) => {
        setEditing(false)
        return actions.updateComment({commentId: comment.id, message})
      }}
      onDiscard={() => setEditing(false)}
    />
  ) : (
    <CommentMessageContent message={comment.message} />
  )
}
```

Return the action from `onSubmit`: `CommentInput` keeps its submit disabled while the returned promise is pending. `autoFocus` fails the `jsx-a11y/no-autofocus` lint rule, which is right in general. Here the user has just chosen Edit, so the comment says why the rule is turned off for that line.

## Show a failed create

`CommentErrorNotice` takes children, so the cause is yours to show or hide. The default copy says only that the post failed:

```tsx
{
  comment.state?.type === 'createError' && (
    <CommentErrorNotice>{comment.state.error.message}</CommentErrorNotice>
  )
}
```

## Render the reactions

The picker already lives in the header here, so `CommentReactions` runs without one. Interactivity comes from `onReactionToggle` alone: leave it out and the pills still show who reacted, but can't be toggled. That's what a viewer without `canReact` gets:

```tsx
<CommentReactions
  reactions={comment.reactions}
  showPicker={false}
  onReactionToggle={capabilities.canReact ? toggleReaction : undefined}
/>
```

One handler serves both, because `active` is the state before the toggle wherever the toggle came from:

```tsx
const toggleReaction = (
  shortName: CommentReactionShortName,
  active: boolean,
) =>
  active
    ? actions.removeReaction({commentId: comment.id, shortName})
    : actions.addReaction({commentId: comment.id, shortName})
```

## Confirm a delete

The dialog is mounted by your card, rendered over the page, and its confirm is the only thing that reaches the SDK:

```tsx
<DeleteCommentDialog
  confirmLabel="Delete forever"
  open={deletePending}
  onClose={() => setDeletePending(false)}
  onConfirm={() => actions.removeComment({commentId: comment.id})}
>
  Deleting this removes its {comment.reactions.length} reactions too.
</DeleteCommentDialog>
```

## Assemble the card

The hooks, handlers, and JSX from the other sections go inside one component. `editing`, `deletePending`, and `flagged` are local state, and `createError` comes from the comment:

**CustomComment.tsx**

```tsx
export function CustomComment({comment}: {comment: CommentData}) {
  const [editing, setEditing] = useState(false)
  const [deletePending, setDeletePending] = useState(false)
  const [flagged, setFlagged] = useState(false)

  const {resolveCapabilities} = useCommentsContext()
  const {activeComment, setActiveComment} = useActiveComment()
  const actions = useCommentActions()
  const author = useCommentUser(comment.authorId)
  const {ref} = useNavigableComment<HTMLDivElement>(comment.id)
  const selectionProps = useSelectionProps(() => setActiveComment(comment.id))
  const {avatar, space} = useTheme_v2()

  const capabilities = resolveCapabilities(comment)
  const selected = activeComment?.commentId === comment.id
  const createError = comment.state?.type === 'createError'
  const dimmed =
    comment.state?.type === 'createRetrying' || comment.status === 'resolved'
  const bodyIndent = avatar.sizes[0].size + space[2]

  const toggleReaction = (
    shortName: CommentReactionShortName,
    active: boolean,
  ) => {
    // ...from "Render the reactions"
  }

  const actionList = [
    // ...from "Build the action list"
  ]

  return (
    <CommentCard
      padding={3}
      radius={3}
      tone={cardTone({createError, selected})}
      style={{opacity: dimmed ? 0.6 : undefined}}
      {...selectionProps}
      ref={ref}
      tabIndex={selectionProps.tabIndex ?? -1}
    >
      <Flex direction="column" gap={2}>
        {/* The CommentHeader from "Compose the header" */}

        <Box style={{paddingLeft: editing ? 0 : bodyIndent}}>
          {/* The CommentInput or CommentMessageContent from "Render the body" */}
        </Box>

        {/* The CommentErrorNotice from "Show a failed create" */}
        {/* The CommentReactions from "Render the reactions" */}
      </Flex>

      {/* The DeleteCommentDialog from "Confirm a delete" */}
    </CommentCard>
  )
}
```

`ActionRow`, `CommentCard`, and `cardTone` stay at module level, outside the component, so they're created once rather than on every render.

## Render the card in a thread

A thread is the parent comment, its replies, and a reply composer. This card renders `CustomComment` for each, with the participants in an `AvatarStack`:

**CommentThreadCard.tsx**

```tsx
import type {CommentThreadData} from '@sanity/collaboration'
import {
  CommentInput,
  useCommentUser,
  UserAvatar,
} from '@sanity/collaboration/ui'
import {useCommentActions} from '@sanity/sdk-react/collaboration'
import {AvatarStack, Card, Stack} from '@sanity/ui'

import {CustomComment} from './CustomComment'

export function CommentThreadCard({thread}: {thread: CommentThreadData}) {
  const {replyToComment} = useCommentActions()
  const participants = [
    ...new Set(
      [thread.parentComment, ...thread.replies].flatMap((comment) =>
        comment.authorId ? [comment.authorId] : [],
      ),
    ),
  ]

  return (
    <Card border padding={2} radius={3}>
      <Stack gap={2}>
        <CustomComment comment={thread.parentComment} />
        {thread.replies.map((reply) => (
          <CustomComment key={reply.id} comment={reply} />
        ))}
        <AvatarStack maxLength={4} size={0}>
          {participants.map((userId) => (
            <ParticipantAvatar key={userId} userId={userId} />
          ))}
        </AvatarStack>
        {thread.status === 'open' && (
          <CommentInput
            placeholder="Reply…"
            draftKey={`reply:${thread.threadId}`}
            onSubmit={(message) =>
              replyToComment({
                parentCommentId: thread.parentComment.id,
                message,
              })
            }
          />
        )}
      </Stack>
    </Card>
  )
}

function ParticipantAvatar(props: {userId: string}) {
  const {user, loading} = useCommentUser(props.userId)

  return <UserAvatar user={user} loading={loading} />
}
```

Render one card per thread inside a `CommentList`, under the provider:

**Comments.tsx**

```tsx
import {SDKCommentsProvider} from '@sanity/collaboration/sdk'
import {CommentList} from '@sanity/collaboration/ui'
import {useCommentThreads} from '@sanity/sdk-react/collaboration'
import {Suspense} from 'react'

import {CommentThreadCard} from './CommentThreadCard'

const handle = {documentId: 'post-123', documentType: 'post'}

export function Comments() {
  return (
    <Suspense fallback={<p>Resolving your identity…</p>}>
      <SDKCommentsProvider>
        <Suspense fallback={<p>Loading comments…</p>}>
          <Threads />
        </Suspense>
      </SDKCommentsProvider>
    </Suspense>
  )
}

function Threads() {
  const {threads} = useCommentThreads(handle)

  return (
    <CommentList threads={threads}>
      {(thread) => <CommentThreadCard key={thread.threadId} thread={thread} />}
    </CommentList>
  )
}
```

`CommentList` is also what reports that the data has settled, which is what makes `onActiveCommentNotFound` fire for a deep link to a comment the list does not show. A layout without `CommentList` has to call `useActiveCommentSettled` itself. See [Handle a link to a comment the panel is hiding](https://www.sanity.io/docs/collaboration/link-to-a-comment).

## Next steps

- To make a list of these respond to deep links: [Link to a comment](https://www.sanity.io/docs/collaboration/link-to-a-comment).
- Every primitive's props: [Storybook](https://collaboration-platform.sanity.dev/?path=/docs/introduction--docs).

