Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.23.30
Accessibility pattern
labelled section, comment list, delete confirmation

Last updated

Comments3

  • PN
    Priya NairInactive

    Customer asked for the revised quote by Friday.

  • AM
    Arjun Mehtaedited

    Sent it over. Details:

    • 12 units
    • delivery in October
  • AR
    Asha 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/comments

The 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

  • PN
    Priya NairInactive

    Customer asked for the revised quote by Friday.

  • AM
    Arjun Mehtaedited

    Sent it over. Details:

    • 12 units
    • delivery in October
  • AR
    Asha Rao

    Thanks — marking this done once they confirm.

Anatomy

CommentList — data-slot="comment-list"
CommentItem — data-slot="comment-edited" | "comment-item" | "comment-replies"
CommentComposer — data-slot="comment-composer"
CommentListSkeleton — data-slot="comment-list-skeleton"

Examples

Empty

"Add a comment" reveals the composer and focuses it. Once comments exist, the composer is always shown.

Comments

No comments yet

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
    • AR
      Asha Raoedited

      Replying to the removed note — still relevant.

Editing and highlighted

  • AR
    Asha Rao
  • AM
    Arjun 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.

API Reference

PropTypeDefaultDescription
comments*CommentData[]—The comments, oldest first (the list reverses them for order="newest").
classNamestring—Classes for the section.
composerReact.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.
countnumbercomments.lengthThe total, shown after the heading; defaults to the comments shown.
emptyTextstring"No comments yet"The empty state's text.
hasEarlierbooleanfalseOlder comments exist: show "Load earlier" before them.
highlightedIdstring—The id of the comment to tint and scroll to (from #comment-<id>).
loadingbooleanfalseShow the skeleton instead of the list.
loadingEarlierbooleanfalseThe older page is loading.
nownumber—Pin the relative times' clock (docs, tests).
onCopyLink((id: string) => void)—Copy a comment's link.
onDeleteMaybeAsync<[id: string]>—Delete a comment.
onEditMaybeAsync<[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.
orderCommentOrder"oldest"The order shown.
renderReplies((comment: CommentData) => React.ReactNode)—Replies under a comment (threads).
titlestring"Comments"The section heading (an h2).

Data attributes and CSS variables on CommentList

AttributeValues
data-slot"comment-list"
PropTypeDefaultDescription
comment*CommentData—The comment.
classNamestring—Classes for the item.
editingboolean—Start in editing mode (controlled when onEditingChange is set).
highlightedbooleanfalseTint the comment — it was opened from its #comment-<id> link.
nownumber—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.
onDeleteMaybeAsync<[id: string]>—Delete after the viewer confirms.
onEditMaybeAsync<[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.
repliesReact.ReactNode—Replies, indented under the comment (one level of CommentItems).

Data attributes and CSS variables on CommentItem

AttributeValues
data-deleted""
data-highlighted""
data-slot"comment-edited" | "comment-item" | "comment-replies"
PropTypeDefaultDescription
onSubmit*MaybeAsync<[body: string]>—Post the Markdown; the editor clears when it resolves and keeps the text when it rejects.
attachmentsReact.ReactNode—Controls at the box's bottom left, before the send button (an attach button).
autoFocusbooleanfalseFocus the editor when the composer mounts.
classNamestring—Classes for the composer.
disabledbooleanfalseDisable the composer.
errorstring—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.
placeholderstring"Leave a comment…"Placeholder in the empty editor.
postingboolean—Mark the composer busy (controlled; otherwise it follows onSubmit's promise).
submitLabelstring"Send comment"Accessible name of the round send button.

Data attributes and CSS variables on CommentComposer

AttributeValues
data-slot"comment-composer"
PropTypeDefaultDescription
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>.
canDeletebooleanfalseThe viewer may delete it (the menu shows Delete).
canEditbooleanfalseThe viewer may edit it (the menu shows Edit).
deletedbooleanfalseSoft-deleted: shown as "Comment deleted" (hide it yourself when it has no replies).
editedAtstring | 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".
KeyAction
Cmd + EnterSend the comment, or save an edit
EscCancel an edit
ContractStates tested
Behaviourempty, loading, list, edited, deleted, editing, posting, error, highlighted
Accessibilitylabeled, browser-accessibility-test
Visualdefault

Do / Don't

Do
Put the composer at the end of the list, and link notifications to #comment-<id>.
Don't
Use comments for system activity (status changes, assignments) — keep those in an activity feed.

On this page