Comment Thread

Pro

Threaded comment display with depth-limited nested replies, an inline reply box, emoji reactions, relative timestamps and an avatar/initials fallback.

</>sv-comment commentsthreadsocial

Complexity tier: Interactive The primitive bar, plus declared async states, a keyboard model, focus restoration and undo for destructive actions.

import { SvCommentComponent } from '@styloviz-pro/display';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Read-only thread

Pro

Editable own comments

Pro

Persisting a reaction, and putting it back

Pro

The pill lights up and its count moves on press — it does not wait for you, because a reaction that needs a round trip reads as a dead button. That leaves the component holding the only copy of the pre-toggle value, so the event carries rollback(). Bind busyIds: it locks the comment while the write is in flight, which is what keeps rollback() unambiguous. Send requestKey so a retried request cannot count the same reaction twice.

Product reviews (rating + verified + helpful)

Pro

variant="review" renders each entry as a product review: a star rating, a verified-purchase badge and a helpful-vote button.

Deleting a comment that has replies

Pro

Dropping the node drops the replies with it. That is fine for a leaf and it is data loss for a parent, because the author asked for their own words to go and not for everyone else's. Mark the parent deleted and leave it in the array: the component renders a tombstone in its place — content, identity, reactions and every action withheld, timestamp kept so the conversation still reads in order — and the replies render underneath exactly as before.

Reporting a failure on the comment that caused it

Pro

error renders once, at the top. In a thread of forty that puts the message a screenful away from the button that produced it, and a second failure overwrites the first. commentErrors is keyed by comment id, renders beside the comment, and reaches nested replies. Key a failed reply by the parentId — the reply itself was never posted and has no id yet.

When to use

  • Displaying discussion threads with nested replies and reactions.
  • You want inline reply, emoji reactions and relative timestamps out of the box.
  • For inline @-references inside a single field, use the Mention input.

Async states

  • loading
  • empty
  • error
  • retry

Item identity & owned state

Restorable state
  • busyIds

Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their Change events to store what the user does.

Server writes

Writes
  • react

Each payload carries a requestKey — send it with the request so a retry cannot apply the same change twice.

Rollback
Call event.rollback() when the save fails — the change is already on screen, and this is what puts it back.
While saving
Bind busyIds — with two writes outstanding there is no single state to roll back to, so the action stays locked until the first one lands.
On failure
Pass the failure to error to show it in place — without taking away what the user was working on.

Inputs

NameTypeDefaultDescription
commentsComment[][]Array of top-level comments with optional nested replies.
currentUserIdstring''ID of the currently logged-in user (for "edit/delete own" logic).
showReplyBoxbooleantrueShow inline reply box below each comment.
maxDepthnumber3Maximum nesting depth.
variant'thread' | 'review''thread'Display mode: threaded discussion, or a product-review list.
busyIdsreadonly string[] | ReadonlySet<string>[]Ids of actions with a request in flight. Those controls disable and show a busy state, so a double-click cannot fire the same request twice — the most common way an action button creates duplicate records.
errorSvDataError | string | nullnullFailure from the last action, when it belongs to no single comment. Rendered above the thread; nothing is removed from view, so the user can see what state things are in. Prefer `commentErrors` for anything that failed on a comment that is on screen — see the note there.
commentErrorsReadonly<Record<string, SvDataError | string>>{}Failures keyed by comment id, rendered beside the comment they belong to. `error` above renders once, at the top. That is right for a failure with no place on the page and wrong for every failure this component actually produces: a reply, an edit, a delete and a reaction all name a comment, and a thread of forty puts the message a screenful away from the button that caused it. Worse, a second failure overwrites the first, so a user who retried two comments sees one message and cannot tell which one it is about. Key a reply failure by its `parentId` — the comment being replied to is what is on screen; the reply itself was never posted and has no id yet. Unlike `error`, this map DOES go down the tree: it is keyed, so a nested reply's failure lands on the reply and nowhere else.
deletedMessagestring'This comment was deleted.'Copy for a comment the host has tombstoned (`deleted: true`).
loadingbooleanfalseTrue while the thread itself is being fetched. Distinct from `busyIds`, which marks individual comments whose *action* is in flight.
loadErrorSvDataError | string | nullnullThe thread could not be loaded. The `…Load` spelling exists because `error` above is already taken, and it means something else: *the reply you just posted failed*. That is a different event with a different recovery — the control is still on screen and you retry by pressing it again, so it is deliberately not retryable. A failed *fetch* leaves nothing to press, so it gets its own error and its own retry. Same precedent as `sv-toolbar` and `sv-tool-call-card`; overloading one input with both meanings is what the rule exists to stop.
emptyMessagestring''Copy for the zero-comment state.
filteredbooleanfalseWhether a filter or search is narrowing the thread, so the empty state can say *no matching comments* rather than *no comments* — telling someone with four hundred comments that there are none reads as a bug.

Outputs

EventPayload typeDescription
retryLoadvoidThe host should re-fetch the thread. Pairs with `loadError`, not `error`.
reply{ parentId: string; text: string }Emitted when a reply is submitted.
reactCommentReactEventA reaction pill was toggled. The pill has already moved — persist it, or call `rollback()`.
deletestringEmitted when a comment is deleted.
edit{ commentId: string; text: string }Emitted when an owned comment is edited and saved.
helpful{ commentId: string }Emitted when a review is marked helpful (review variant).

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `comments`, `loading` and `error`.
  • Set `filtered` when a search or filter is active. It is the difference between "you have no orders" and "no orders match this filter" — telling a customer with 4,000 orders the first one reads as a bug.
  • The load-level retry is `retryLoad` here, because `retry` already means a per-item action on this component.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Cap nesting with `maxDepth` and toggle the inline `showReplyBox`.
  • Identify the viewer via `currentUserId` to reveal inline Edit and Delete on their own comments.
  • Handle `reply`, `react`, `edit` and `delete` outputs to persist changes.
  • Reactions are optimistic: the pill and its count move on press and are reconciled against the next `comments` value you supply, so nothing has to be re-rendered to make a press feel immediate.
  • `busyIds` locks one comment's controls mid-write and reaches nested replies automatically; `error` reports a failed action above the thread, `loading` / `loadError` / `retryLoad` cover the fetch.
  • Comment bodies wrap long words and URLs (`break-words`) so they never overflow the bubble on narrow screens.
  • A `review` variant renders comments as product reviews (star rating, verified-purchase badge and helpful votes); the Comment type gains `rating`, `verified` and `helpfulCount`.
  • `deleted: true` renders a tombstone in place of a comment — withheld content and identity, no actions, timestamp kept, replies intact — and `deletedMessage` supplies the wording.
  • `commentErrors` is a map of comment id to failure, rendered beside that comment and passed down to nested replies; `error` stays for a failure that belongs to no comment on screen.

Best practices

  • Do: Limit nesting with `maxDepth` so deep threads stay readable on mobile.
  • Do: Pass `currentUserId` so edit/delete only appear on the viewer's own comments.
  • Do: Bind `busyIds` whenever you handle `react`. It locks the comment while its write is in flight, which is the only thing that keeps `rollback()` unambiguous — and it is passed down to nested replies for you.
  • Do: Call `rollback()` from the error branch of the reaction write. The pill has already moved; nothing else puts it back.
  • Do: Send `requestKey` with the write and have the server ignore a key it has already applied. An interceptor that retries a timeout otherwise turns one reaction into two.
  • Do: Apply the event's `reacted` / `count` when the write succeeds rather than toggling what is in your array, so the same answer arriving twice cannot invert it back.
  • Don't: Don't expect the pill to wait for your array. It moves on press, and the value you hand back next is what wins — so a `react` handler that does nothing leaves the pill showing an unsaved reaction forever.
  • Don't: Don't use `error` for a failed fetch. It renders above a thread that is still there; `loadError` replaces the thread and carries its own retry.
  • Do: Mark a parent `deleted: true` and leave it in the array when it has replies. Removing the node removes the replies, which is other people's writing.
  • Do: Prefer `commentErrors` over `error` for anything that failed on a comment that is on screen — a reply, an edit, a delete, a reaction. It puts the message beside the button that caused it instead of a screenful away, and two failures no longer overwrite each other.
  • Do: Report a delete failure through `commentErrors` (or `error`) rather than silently leaving the comment in place. It is also what returns focus to the Delete button that was pressed.
  • Don't: Don't move focus yourself after `delete`. The component waits until your array no longer contains the comment, then lands on whatever took its place — moving focus at emit time parks it on a button that is about to disappear.

Accessibility

  • Reaction toggles expose `aria-pressed` and a descriptive label (emoji + count), so state is not conveyed by color alone — and both track the optimistic value, so the announced state matches what is drawn.
  • A comment in `busyIds` disables its pills, Delete and Send and marks them `aria-busy`, so an in-flight write is announced rather than only implied by an unresponsive button.
  • Opening reply or edit moves focus into the textarea, which carries an explicit label; Edit exposes `aria-expanded`.
  • Timestamps expose readable values and avatars fall back to initials with an accessible name.
  • Focus is never dropped: posting a reply returns it to that comment's Reply button, saving or cancelling an edit returns it to Edit, and a failed delete returns it to Delete. Each of those controls destroys the element that had focus, and the browser's only fallback is the top of the page.
  • A confirmed delete moves focus to the comment that took its place — the previous one when the last comment goes, the thread itself when none is left — and announces the removal in a polite live region, since a comment simply ceasing to exist is otherwise silent.

Feedback on Comment Thread

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.