Collaboration (Beta)

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

The primitives are internal

Every snippet on this page goes into one file, which opens with every import the snippets use. Assemble the card shows where each snippet goes:

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:

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:

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 uses this for its participant avatars:

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:

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:

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

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:

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

Apply it as the card's opacity:

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:

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

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.

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:

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

Pad the body by that indent, except while editing:

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

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

Inside, the message or the editor:

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

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

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

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:

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

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:

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:

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.

Next steps

Was this page helpful?

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