# Comment Thread

> A React comment thread for records: replies, reactions, edit and delete, @mentions and a composer that sends optimistically with retry.

- Type: React component (Data Display)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add comment-thread`
- Docs: https://wingo-ui.com/components/comment-thread

## Interaction

Write in the composer at the bottom and send with the arrow or Cmd/Ctrl+Enter (Enter adds a line; with sendOnEnter, Enter sends and Shift+Enter adds a line). The comment appears at once at 60% opacity with "Sending…", then fades to its time; if the request fails it shakes once and offers Retry and Discard. Type @ and a few letters for the people list: Up and Down move, Enter or Tab insert "@Anna Price ", Escape closes it. Press Up in the empty composer to edit your last comment. Reply opens an inline composer under the thread (Escape cancels, replying to a reply prefills the @name); Edit turns the text into a box with Save and Cancel (Cmd/Ctrl+Enter saves, Escape cancels, an empty text cannot be saved). Tap a reaction pill to toggle yours; the smiley opens the emoji row. The ⋯ menu has Edit, Copy text, Copy link and Delete, which asks first. "Show N more replies" unfolds older replies, the sort button flips oldest and newest first. On a phone every action is visible with a 44px target, the menu and the emoji row open as bottom sheets, and a long press on a comment opens its actions sheet.

## Installation

```bash
npx wingo-ui@latest add comment-thread
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/comment-thread` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/comment-thread.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Kbd](https://wingo-ui.com/components/kbd) ([Markdown](https://wingo-ui.com/components/kbd.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [useClipboard](https://wingo-ui.com/components/use-clipboard) ([Markdown](https://wingo-ui.com/components/use-clipboard.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useLongPress](https://wingo-ui.com/components/use-long-press) ([Markdown](https://wingo-ui.com/components/use-long-press.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
"use client"

import { useState } from "react"
import { CommentThread, type CommentAuthor, type CommentData } from "@/components/ui/comment-thread"

const anna: CommentAuthor = { id: "anna", name: "Anna Price", avatar: "/demo/ana-pop.jpg" }
const michael: CommentAuthor = { id: "michael", name: "Michael Hayes", avatar: "/demo/mihai-ionescu.jpg", badge: "Author" }

export function QuoteDiscussion() {
  const [comments, setComments] = useState<CommentData[]>([
    {
      id: "c1",
      author: michael,
      body: "Sent the revised quote with 5% off the annual plan. @Anna Price can you take a look?",
      createdAt: "2026-09-24T14:05:00",
      reactions: [{ emoji: "👍", count: 3, reacted: false, users: ["Jessica Dunn", "Ryan Martin", "Liam Brooks"] }],
    },
  ])

  const send = async (body: string, { parentId }: { parentId?: string }) => {
    // await api.post("/quotes/Q-2026-118/comments", { body, parentId })
    const saved: CommentData = { id: crypto.randomUUID(), author: anna, body, createdAt: new Date() }
    setComments((list) =>
      parentId
        ? list.map((c) => (c.id === parentId ? { ...c, replies: [...(c.replies ?? []), saved] } : c))
        : [...list, saved],
    )
    return saved.id
  }

  return (
    <CommentThread
      comments={comments}
      currentUser={anna}
      people={[anna, michael]}
      onSubmit={send}
      onEdit={(id, body) => setComments((list) => list.map((c) => (c.id === id ? { ...c, body, editedAt: new Date() } : c)))}
      onDelete={(id) => setComments((list) => list.map((c) => (c.id === id ? { ...c, deleted: true } : c)))}
      labels={{ title: "Discussion", empty: "No comments yet" }}
      placeholder="Write a comment…"
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `comments?` | `CommentData[]` | `"[]"` | { id, author: { id, name, avatar?, badge? }, body, createdAt, editedAt?, replies?, reactions?: { emoji, count, reacted, users? }[], attachments?, pending?, failed?, deleted? }. Bodies are plain text; replies nest one level (deeper ones are flattened with the @name in front). |
| `currentUser?` | `CommentAuthor` |  | You. Turns on the composer, Reply, reactions and the actions on your own comments. Without it the thread is read-only. |
| `people?` | `CommentAuthor[]` | `"[]"` | Who can be mentioned: @Name in bodies becomes a chip (yours is highlighted) and the composer suggests them after @. |
| `reactions?` | `string[]` | `"[\"👍\", \"❤️\", \"😂\", \"🎉\", \"😮\", \"🙏\"]"` | The emoji row of the reaction picker. |
| `placeholder?` | `string` | `"Write a comment…"` | The empty composer's hint. Replies use labels.replyPlaceholder(name). |
| `variant?` | `"plain" \| "bubble"` | `"plain"` | plain: flat comments with a thread line to the replies, like GitHub. bubble: the name and text in a bubble, your own bubbles tinted with the tone, time and actions underneath. |
| `size?` | `"sm" \| "md"` | `"md"` | Avatars 28 / 36px (replies 24 / 28px), text 13 / 14px. sm fits side panels. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Your own bubbles, your reactions, the send and save buttons. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. |
| `timeFormat?` | `"relative" \| "time" \| "date" \| "datetime"` | `"relative"` | "5 min. ago", "4:40 PM", "9/24/2026" or both. The full date is always in the tooltip. |
| `showHeader?` | `boolean` | `true` | The title row with the count and the sort button. |
| `composer?` | `"bottom" \| "top" \| "none"` | `"bottom"` | Where the composer sits; top suits newest-first feeds. Needs currentUser and onSubmit. |
| `stickyComposer?` | `boolean` | `false` | The composer sticks to the edge of the scroll container on a glass background and pads the safe area, for chat-like pages on phones. Set --comment-thread-surface to the background behind it and --comment-thread-bleed to reach the container's edges. |
| `sort?` | `"oldest" \| "newest"` | `"oldest"` | Order of the top-level comments; replies are always oldest first. The header button flips it (onSortChange). |
| `collapseRepliesAfter?` | `number` | `2` | Shows the newest N replies and folds the older ones behind "Show N more replies". |
| `loading?` | `boolean` | `false` | Skeleton comments with the real shape. |
| `skeletonCount?` | `number` | `3` | How many skeleton comments show while loading. |
| `confirmDelete?` | `boolean` | `true` | Delete asks first in an alert dialog (a returned promise keeps it loading). |
| `sendOnEnter?` | `boolean` | `false` | Chat style: Enter sends and Shift+Enter adds a line (touch keyboards keep Enter for new lines and show a Send key). By default Enter adds a line and Cmd/Ctrl+Enter sends. |
| `locale?` | `string` | `""` | Locale for the times. Empty uses the UIProvider locale, then the browser's. |
| `onSubmit?` | `(body: string, context: { parentId?: string; mentions: string[] }) => (void \| Promise<unknown>)` |  | A new comment or reply. The optimistic row shows at once; a promise keeps it "Sending…" and a rejection marks it failed with Retry. Resolve with the saved comment's id so the row stays in place when it arrives in comments. |
| `onEdit?` | `(id: string, body: string) => (void \| Promise<unknown>)` |  | Saves an edit. A promise keeps Save loading; a rejection keeps the editor open with an error. |
| `onDelete?` | `(id: string) => (void \| Promise<unknown>)` |  | Deletes a comment after the confirmation. Mark it deleted to keep the placeholder above its replies. |
| `onReact?` | `(id: string, emoji: string) => void` |  | Toggles the current user's reaction. |
| `onRetry?` | `(id: string) => void` |  | Retry on a comment you marked failed yourself. Optimistic comments from onSubmit retry on their own. |
| `onSortChange?` | `(sort: CommentThreadSort) => void` |  | The header sort button was pressed. |
| `canEdit?` | `(comment: CommentData) => boolean` |  | Default: your own comments (needs onEdit). |
| `canDelete?` | `(comment: CommentData) => boolean` |  | Default: your own comments (needs onDelete). Return true for moderators. |
| `getLink?` | `(comment: CommentData) => string` |  | What Copy link copies. Default: the page URL with #comment-<id> (every comment has that id). |
| `emptyState?` | `ReactNode` |  | Replaces the default <EmptyState size="sm" icon={<MessageSquare />} title={labels.empty} />. |
| `composerProps?` | `Partial<CommentComposerProps>` |  | Extra props for the main composer: attachments, maxLength, autoFocus, submitLabel. |
| `labels?` | `Partial<CommentThreadLabels>` |  | Every string, English by default (DEFAULT_COMMENT_THREAD_LABELS): title, reply, edit, delete, edited, save, cancel, send, sending, retry, discard, failed, deleted, showReplies(n), hideReplies, react, reactedBy(names, count), empty, deleteTitle, deleteConfirm, sortOldest, sortNewest, mention, actions, copyText, copyLink, newComment(name), replyPlaceholder(name)… |
| `classNames?` | `Partial<Record<CommentThreadSlot, string>>` |  | header, title, count, sort, list, comment, avatar, main, author, time, body, bubble, attachments, reactions, footer, replies, composer. |

## Parts

### CommentItem (component)

One comment with its replies. Inside a CommentThread it gets the thread's actions; on its own it renders read-only. Props: { comment: CommentData; parentId?: string } & ComponentProps<"li">.

### CommentComposer (component)

The composer on its own (a chat input, a review box): auto-growing box, @mention suggestions, Cmd/Ctrl+Enter or Enter to send, a counter with maxLength. A returned promise keeps the send button busy and the text until it resolves. The mention list opens above the box (suggestionsSide="bottom" for a composer at the top; the thread sets it for composer="top"). Props: { value?, defaultValue?, onValueChange?, onSubmit?(body, { mentions }), people?, user?, placeholder?, submitLabel?, autoFocus?, maxLength?, sendOnEnter?, attachments?, disabled?, size?, tone?, color?, onCancel?, onEditLast?, suggestionsSide?: "top" | "bottom", labels?, classNames? }.

### CommentBody (component)

The plain-text renderer: line breaks kept, @mentions as chips, URLs as links (new tab, noopener). Never renders HTML. Props: { body: string; people?: CommentAuthor[]; currentUserId?: string }.

### parseMentions (function)

Splits text into text, mention and link segments. Signature: (text: string, people: CommentAuthor[]) => CommentSegment[].

### getMentionIds (function)

The ids of the people mentioned in a text, each once, in order. The composer passes them to onSubmit as mentions, so you can notify those people. Signature: (text: string, people: CommentAuthor[]) => string[].

### DEFAULT_COMMENT_THREAD_LABELS (constant)

Every English string: title ("Comments"), the action names, showReplies(n), reactedBy(names, count), the empty state, the delete dialog, sort labels, the copy menu and the composer placeholders. Spread it into labels to translate a few. Type: CommentThreadLabels.

### CommentData (type)

One comment: { id, author, body, createdAt, editedAt?, replies?, reactions?, attachments?, pending?, failed?, deleted? }. Bodies are plain text with @Name mentions.

### CommentAuthor (type)

A person who writes or is mentioned: { id, name, avatar?, badge? }.

## Examples

- **Chat on a phone**: Bubbles, a sticky glass composer with the safe area, Enter to send.
- **Mentions**: @ opens the team list; @Name tokens in bodies become chips.
- **Long threads fold**: Older replies hide behind “Show 5 more replies”.
- **Read-only**: Without currentUser the thread only shows: no composer, no actions.
- **Small, in a side panel**: size="sm" next to an overdue invoice.
- **Brand bubbles, newest first**: Your bubbles in the tone; the composer on top for feeds.
- **Empty thread**: The empty state, an attach button and a 500 character limit.

## See also

- [Timeline](https://wingo-ui.com/components/timeline) ([Markdown](https://wingo-ui.com/components/timeline.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Emoji Reaction](https://wingo-ui.com/components/emoji-reaction) ([Markdown](https://wingo-ui.com/components/emoji-reaction.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))

---

Canonical: https://wingo-ui.com/components/comment-thread
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
