Comment Thread React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React comment thread for records: replies, reactions, edit and delete, @mentions and a composer that sends optimistically with retry.
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.
Comment Thread is a Pro React component in the React data display components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add comment-thread, together with Alert Dialog, Avatar, Badge, Button, Dropdown Menu and 15 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add comment-threadComment Thread is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import CommentThread from @/components/ui/comment-thread.
"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
commentsdefault"[]"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).
currentUserCommentAuthorYou. Turns on the composer, Reply, reactions and the actions on your own comments. Without it the thread is read-only.
peopledefault"[]"CommentAuthor[]Who can be mentioned: @Name in bodies becomes a chip (yours is highlighted) and the composer suggests them after @.
reactionsdefault"["👍", "❤️", "😂", "🎉", "😮", "🙏"]"string[]The emoji row of the reaction picker.
placeholderdefault"Write a comment…"stringThe empty composer's hint. Replies use labels.replyPlaceholder(name).
variantdefault"plain""plain" | "bubble"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.
sizedefault"md""sm" | "md"Avatars 28 / 36px (replies 24 / 28px), text 13 / 14px. sm fits side panels.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Your own bubbles, your reactions, the send and save buttons.
colordefault""stringAny CSS color; replaces the tone.
timeFormatdefault"relative""relative" | "time" | "date" | "datetime""5 min. ago", "4:40 PM", "9/24/2026" or both. The full date is always in the tooltip.
showHeaderdefaulttruebooleanThe title row with the count and the sort button.
composerdefault"bottom""bottom" | "top" | "none"Where the composer sits; top suits newest-first feeds. Needs currentUser and onSubmit.
stickyComposerdefaultfalsebooleanThe 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.
sortdefault"oldest""oldest" | "newest"Order of the top-level comments; replies are always oldest first. The header button flips it (onSortChange).
collapseRepliesAfterdefault2numberShows the newest N replies and folds the older ones behind "Show N more replies".
loadingdefaultfalsebooleanSkeleton comments with the real shape.
skeletonCountdefault3numberHow many skeleton comments show while loading.
confirmDeletedefaulttruebooleanDelete asks first in an alert dialog (a returned promise keeps it loading).
sendOnEnterdefaultfalsebooleanChat 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.
localedefault""stringLocale 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) => voidToggles the current user's reaction.
onRetry(id: string) => voidRetry on a comment you marked failed yourself. Optimistic comments from onSubmit retry on their own.
onSortChange(sort: CommentThreadSort) => voidThe header sort button was pressed.
canEdit(comment: CommentData) => booleanDefault: your own comments (needs onEdit).
canDelete(comment: CommentData) => booleanDefault: your own comments (needs onDelete). Return true for moderators.
getLink(comment: CommentData) => stringWhat Copy link copies. Default: the page URL with #comment-<id> (every comment has that id).
emptyStateReactNodeReplaces the default <EmptyState size="sm" icon={<MessageSquare />} title={labels.empty} />.
composerPropsPartial<CommentComposerProps>Extra props for the main composer: attachments, maxLength, autoFocus, submitLabel.
labelsPartial<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)…
classNamesPartial<Record<CommentThreadSlot, string>>header, title, count, sort, list, comment, avatar, main, author, time, body, bubble, attachments, reactions, footer, replies, composer.
Parts
CommentItem
componentOne 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
componentThe 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
componentThe 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
functionSplits text into text, mention and link segments. Signature: (text: string, people: CommentAuthor[]) => CommentSegment[].
getMentionIds
functionThe 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
constantEvery 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
typeOne comment: { id, author, body, createdAt, editedAt?, replies?, reactions?, attachments?, pending?, failed?, deleted? }. Bodies are plain text with @Name mentions.
CommentAuthor
typeA 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.
Dependencies
Files
- components/ui/comment-thread.tsx
FAQ
Is the React Comment Thread component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Comment Thread?
Run npx wingo-ui@latest add comment-thread. It also adds the Wingo UI items it builds on (Alert Dialog, Avatar, Badge, Button, Dropdown Menu and 15 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/comment-thread.
How does Comment Thread behave on phones?
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.
See also
Used in
More React data display components
Articles using this component
- React Emoji Picker: Search, Skin Tones and Phone Sheets
Component guides9 min read
- React Mentions: @People and #Tags That Survive Renames
Component guides9 min read
Comments6
Comment deleted