Marble UI
← Components

Comment Thread

Preview: Inline

Discussion thread: anchored quote, comments with reactions, reply box, resolve.

Import

import { CommentThread } from '@marbleui/ui'

Props

<CommentThread>

PropTypeDefaultDescription
comments *requiredComment[]—
anchorstring—Quoted text the thread is anchored to (inline document comments).
classNamestring—
currentUserstring'You'
defaultResolvedbooleanfalse
onReact(commentId: string, emoji: string) => void—
onReply(text: string) => void—
onResolvedChange(resolved: boolean) => void—Shows the Resolve/Reopen button.
resolvedboolean—Resolved threads collapse to one line; changing it later collapses or expands the thread to match.

Usage notes

Discussion attached to a spot in a document or design: an optional quoted anchor, comments with emoji reactions, a reply box, and Resolve/Reopen. For a reaction row under a single message use ReactionBar.

<CommentThread
  anchor="The drawer opens at full height on small screens"
  comments={comments}
  onReply={(text) => addComment(threadId, text)}
  onReact={(commentId, emoji) => toggleReaction(commentId, emoji)}
  resolved={resolved}
  onResolvedChange={setResolved}
/>
  • Fully controlled: the thread never changes comments itself. Append the reply in onReply, and update counts and mine in onReact.
  • Parts appear only with their callback: no onReply, no reply box; no onResolvedChange, no Resolve button; no onReact, no quick reactions, and existing reaction chips are shown as plain text rather than buttons.
  • resolved is controlled, or use defaultResolved with onResolvedChange. A resolved thread collapses to a “show” row and hides the reply box, and it collapses or expands again whenever resolved changes. “show” moves focus to the comment list.
  • ⌘/Ctrl+Enter sends a reply.
  • time is displayed as given (“2h ago”). currentUser only names the reply field (“Reply as …”).
Example source · show
import { useState } from 'react'
import { CommentThread, type Comment } from './comment-thread'

const initial: Comment[] = [
  { id: '1', author: 'Maya Chen', time: '2h ago', body: 'Should the drawer snap to 40% on mobile? It covers the map completely right now.', reactions: [{ emoji: '👍', count: 2 }] },
  { id: '2', author: 'Charles Webb', time: '1h ago', body: 'Agreed. 40% and 90% detents, with the handle always visible.', edited: true },
]

function Demo() {
  const [comments, setComments] = useState(initial)
  const [resolved, setResolved] = useState(false)
  return (
    <CommentThread
      className="w-96"
      anchor="The drawer opens at full height on small screens"
      comments={comments}
      resolved={resolved}
      onResolvedChange={setResolved}
      onReply={(text) => setComments((c) => [...c, { id: String(Date.now()), author: 'You', time: 'now', body: text }])}
      onReact={(id, emoji) =>
        setComments((cs) =>
          cs.map((c) => {
            if (c.id !== id) return c
            const existing = c.reactions?.find((r) => r.emoji === emoji)
            const reactions = existing
              ? c.reactions!.map((r) => (r.emoji === emoji ? { ...r, mine: !r.mine, count: r.count + (r.mine ? -1 : 1) } : r)).filter((r) => r.count > 0)
              : [...(c.reactions ?? []), { emoji, count: 1, mine: true }]
            return { ...c, reactions }
          }),
        )
      }
    />
  )
}

export const styles = { Inline: Demo, Resolved: () => <CommentThread className="w-96" comments={initial} resolved onResolvedChange={() => {}} /> }

Source: packages/ui/src/comment-thread/comment-thread.tsx

Catalog

Category
editor
Priority
Pro
Primitive
composed
React Aria
GridList + TextField