Comments
A record's comments — a list with a count, an Oldest or Newest first toggle, skeleton and empty states, in-place editing, and a Linear-style composer.
- Status
- Since
0.23.30- Accessibility pattern
- labelled section, comment list, delete confirmation
Last updated
Comments3
- PNPriya Nair
Customer asked for the revised quote by Friday.
- AMArjun Mehtaedited
Sent it over. Details:
- 12 units
- delivery in October
- ARAsha Rao
Thanks — marking this done once they confirm.
Install
Add Comments from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/commentsThe same command installs the registry items it composes: @vegastack/alert-dialog, @vegastack/button, @vegastack/data-table-parts, @vegastack/empty, @vegastack/markdown-view, @vegastack/person-hover-card, @vegastack/relative-time, @vegastack/searchable-select, @vegastack/skeleton, @vegastack/text-edit, @vegastack/tooltip.
Usage
import {
CommentComposer,
CommentList,
type CommentData,
} from "@/components/ui/comments";
<CommentList
comments={comments}
count={total}
hasEarlier={!!cursor}
onLoadEarlier={loadEarlier}
highlightedId={hashCommentId}
onEdit={(id, body) => updateComment(id, body)}
onDelete={(id) => deleteComment(id)}
onCopyLink={(id) => copy(`${url}#comment-${id}`)}
composer={<CommentComposer onSubmit={(body) => postComment(body)} />}
/>;CommentList is a record's discussion: an h2 "Comments" with the count and, when onOrderChange
is set, an "Oldest first" / "Newest first" toggle; "Load earlier" when older comments exist; the
comments; and the composer at the end. Pass comments oldest first; order="newest" reverses
them. With no comments, the empty state offers "Add a comment", which reveals the composer and
focuses it.
CommentComposer is Linear style: a soft filled box whose editor grows with its text, a round ↑
send button at the bottom right (disabled while empty; Cmd/Ctrl+Enter also sends) and an optional
attachments slot at the bottom left. No avatar sits beside it. Editing a comment uses the same box,
compact: a round ↑ Save (disabled while empty or unchanged) and a ghost × Cancel, both with
tooltips; Cmd/Ctrl+Enter saves and Esc cancels. The parts take data and
callbacks and keep only transient UI state. A callback that returns a promise drives the saving or
posting state. If the promise rejects, its message appears under the editor. Bodies are Markdown,
edited with TextEdit (no toolbar; a reduced slash menu) and rendered with
MarkdownView.
Each comment is anchored at #comment-<id>. Pass that id as highlightedId to tint it and scroll
it into view. The ⋯ menu shows Copy link when onCopyLink is set, and Edit or Delete when the
comment's canEdit or canDelete allows them. Delete asks for confirmation first.
Comments3
- PNPriya Nair
Customer asked for the revised quote by Friday.
- AMArjun Mehtaedited
Sent it over. Details:
- 12 units
- delivery in October
- ARAsha Rao
Thanks — marking this done once they confirm.
Anatomy
Examples
Empty
"Add a comment" reveals the composer and focuses it. Once comments exist, the composer is always shown.
Comments
Loading
Comments
Edited, deleted with replies
A soft-deleted comment with replies stays as "Comment deleted" so the thread keeps its place. Hide
one with no replies yourself. replies (or renderReplies on the list) indents one level of
CommentItems.
- Comment deleted
- ARAsha Raoedited
Replying to the removed note — still relevant.
Editing and highlighted
- ARAsha Rao
- AMArjun Mehtaedited
Sent it over. Details:
- 12 units
- delivery in October
Posting and error
posting and error are controlled overrides. Without them the composer follows onSubmit's
promise: it shows as busy while the promise is pending, clears on success and keeps the text on
failure.
Couldn't post the comment. Check your connection and try again.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
comments* | CommentData[] | — | The comments, oldest first (the list reverses them for order="newest"). |
className | string | — | Classes for the section. |
composer | React.ReactNode | — | The composer, at the section's end. With no comments it waits behind the empty state's "Add a comment" button, then shows and takes focus. |
count | number | comments.length | The total, shown after the heading; defaults to the comments shown. |
emptyText | string | "No comments yet" | The empty state's text. |
hasEarlier | boolean | false | Older comments exist: show "Load earlier" before them. |
highlightedId | string | — | The id of the comment to tint and scroll to (from #comment-<id>). |
loading | boolean | false | Show the skeleton instead of the list. |
loadingEarlier | boolean | false | The older page is loading. |
now | number | — | Pin the relative times' clock (docs, tests). |
onCopyLink | ((id: string) => void) | — | Copy a comment's link. |
onDelete | MaybeAsync<[id: string]> | — | Delete a comment. |
onEdit | MaybeAsync<[id: string, body: string]> | — | Save an edit. |
onLoadEarlier | (() => void) | — | Load the older page. |
onOrderChange | ((order: CommentOrder) => void) | — | Called from the header's "Oldest first" / "Newest first" toggle; the toggle shows when set. |
order | CommentOrder | "oldest" | The order shown. |
renderReplies | ((comment: CommentData) => React.ReactNode) | — | Replies under a comment (threads). |
title | string | "Comments" | The section heading (an h2). |
Data attributes and CSS variables on CommentList
| Attribute | Values |
|---|---|
data-slot | "comment-list" |
| Prop | Type | Default | Description |
|---|---|---|---|
comment* | CommentData | — | The comment. |
className | string | — | Classes for the item. |
editing | boolean | — | Start in editing mode (controlled when onEditingChange is set). |
highlighted | boolean | false | Tint the comment — it was opened from its #comment-<id> link. |
now | number | — | Pin the relative time's clock (docs, tests). |
onCopyLink | ((id: string) => void) | — | Copy a link to the comment; the menu shows Copy link when set. |
onDelete | MaybeAsync<[id: string]> | — | Delete after the viewer confirms. |
onEdit | MaybeAsync<[id: string, body: string]> | — | Save an edit: called with the comment's id and the new Markdown. |
onEditingChange | ((editing: boolean) => void) | — | Called when editing starts or ends. |
replies | React.ReactNode | — | Replies, indented under the comment (one level of CommentItems). |
Data attributes and CSS variables on CommentItem
| Attribute | Values |
|---|---|
data-deleted | "" |
data-highlighted | "" |
data-slot | "comment-edited" | "comment-item" | "comment-replies" |
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit* | MaybeAsync<[body: string]> | — | Post the Markdown; the editor clears when it resolves and keeps the text when it rejects. |
attachments | React.ReactNode | — | Controls at the box's bottom left, before the send button (an attach button). |
autoFocus | boolean | false | Focus the editor when the composer mounts. |
className | string | — | Classes for the composer. |
disabled | boolean | false | Disable the composer. |
error | string | — | An error under the box (controlled; otherwise a rejected onSubmit's message). |
onValueChange | ((value: string) => void) | — | Called on every change, e.g. to guard unsaved text. |
placeholder | string | "Leave a comment…" | Placeholder in the empty editor. |
posting | boolean | — | Mark the composer busy (controlled; otherwise it follows onSubmit's promise). |
submitLabel | string | "Send comment" | Accessible name of the round send button. |
Data attributes and CSS variables on CommentComposer
| Attribute | Values |
|---|---|
data-slot | "comment-composer" |
| Prop | Type | Default | Description |
|---|---|---|---|
author* | Person | — | Who wrote it — name, image and a status badge such as "Inactive". |
body* | string | — | The body, in Markdown. Ignored once deleted. |
createdAt* | string | number | Date | — | When it was posted. |
id* | string | — | Stable id; the item's anchor is comment-<id>. |
canDelete | boolean | false | The viewer may delete it (the menu shows Delete). |
canEdit | boolean | false | The viewer may edit it (the menu shows Edit). |
deleted | boolean | false | Soft-deleted: shown as "Comment deleted" (hide it yourself when it has no replies). |
editedAt | string | number | Date | — | When it was last edited; shows "edited" with this time on hover. |
Accessibility
- The section is labelled by its heading. The comments are a list, and replies are a nested list named "Replies".
- Each comment's ⋯ trigger is named for its author ("Actions for comment by Asha Rao").
- The relative time shows the exact time on hover or focus. "edited" does the same with the edit time.
- Errors are announced with
role="alert".
| Key | Action |
|---|---|
| Cmd + Enter | Send the comment, or save an edit |
| Esc | Cancel an edit |
| Contract | States tested |
|---|---|
| Behaviour | empty, loading, list, edited, deleted, editing, posting, error, highlighted |
| Accessibility | labeled, browser-accessibility-test |
| Visual | default |