Comment Thread
A nested discussion tree — collapsible reply subtrees, optimistic voting, an inline reply composer with a posting/failed lifecycle, and a depth cap that hands deep threads off to a permalink.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/comment-thread.jsonPrompt
Build a React + TypeScript + Tailwind "CommentThread" component with zod and
lucide-react.
Contract
- A recursive zod schema in a sibling contract file is the single source of
truth for one node: { id, author: { name, avatarUrl: string | null, badge? },
body, timeLabel, edited?, score?, viewerVote?: "up"|"down"|null,
replies: Comment[], collapsed?, pinned?, deleted?, status?:
"posting"|"failed" }. Because the type references itself, declare the
`Comment` interface by hand and annotate the schema with it —
`export const commentSchema: z.ZodType<Comment> = z.lazy(() => z.object({…
replies: z.array(commentSchema) …}))`. `z.lazy` defers evaluation so the
schema can name itself; the annotation is what makes TypeScript accept a
type defined in terms of itself.
- `timeLabel` is ALREADY FORMATTED ("2h ago", "Jul 12") — the component never
calls new Date() / Intl.*, so server and client render the same string.
Sorting by time therefore belongs to the consumer, which owns the real
timestamps.
- A separate thread-level union — "loading" | "empty" | "error" | "ready" —
is the fetch state, independent of a single comment's own `status`.
- Props: comments: Comment[]; status; maxDepth = 5; sort?: "top"|"new"|"old"
+ onSortChange?; onVote?(id, dir); onReply?(id, body);
onCollapseToggle?(id, collapsed); onRetry?(); onRetryPost?(id);
onContinueThread?(id); emptyState?; skeletonRows = 3; className + the
remaining div props, forwardRef on the root.
Behavior
- Four first-class branches on `status`: loading → skeleton rows at staggered
indents; empty → icon+text slot, overridable via `emptyState`; error →
message plus a "Try again" button only when `onRetry` is passed; ready →
the tree.
- Collapsing hides the WHOLE subtree of a comment, not just one level. Two
affordances drive it: the action-row button (accessible name "Collapse
Ada's 3 replies" / "Expand …", `aria-expanded`, `aria-controls` pointing at
the subtree list while it exists) and the vertical thread line in the left
rail, which is a mouse-only twin — `tabIndex={-1}` + `aria-hidden` so the
same action is not tabbable or announced twice. Collapsed comments show
"N replies", N counting every descendant, not just direct children.
- Collapse and vote are optimistic-with-takeover, not blindly local: each
reader action is stored as `{ value, base }` where `base` is the data value
it was applied on top of. The override is only honoured while
`base === current data`; the moment the consumer's data changes — server
confirmed OR rejected — `base` stops matching and the incoming data wins.
That needs no effect, no syncing, and cannot double-count a vote.
- Votes: pressing the active direction again clears the vote. Displayed score
is `score + weight(effectiveVote) − weight(dataVote)`, so the number moves
the instant it is clicked and lands on the server's number when it arrives.
With no `onVote` handler the score renders as a static chip — never a dead
button.
- Reply: "Reply" opens an inline composer directly under that comment (only
one at a time). The textarea takes focus on mount via a module-level ref
callback (an inline `ref={el => el?.focus()}` re-attaches every render and
steals focus back on each keystroke). Esc cancels, ⌘/Ctrl+Enter submits, a
bare Enter stays a newline. Submitting calls `onReply(id, trimmedBody)` and
returns focus to the "Reply" button — after checking `isConnected`, since
the re-render that inserts the new node may have unmounted it. The
component does NOT insert anything itself: the consumer appends the node
with `status: "posting"` (dimmed row, "Sending…", vote/reply hidden), then
clears it on success or flips it to `"failed"` — which renders a
destructive-tinted row with a Retry button wired to `onRetryPost(id)`.
- Depth: `maxDepth` (clamped to >= 1) caps how far the indent ladder grows.
At the cap the subtree either moves out of the indented column and keeps
rendering flat, or — when `onContinueThread` is supplied — is replaced by a
"Continue this thread (N)" entry that hands the comment id to the consumer
for a permalink / re-root; the collapse control goes away with it, since
there is nothing left inline to fold. Nothing is ever dropped silently.
- Sorting is controlled and never applied internally: the sort bar (rendered
only when `onSortChange` exists) reports "top" | "new" | "old" and the
consumer returns a reordered tree. Pinned-first ordering is likewise the
data layer's decision — `pinned` only draws a chip.
- Edge cases that must hold: a leaf shows no collapse control at all; a
`deleted` comment renders a tombstone ("[deleted]" + "This comment was
removed.") that drops voting and replying but KEEPS its collapse control
(its replies are real, and the rail line alone would make folding them
mouse-only) — and neither the avatar initials nor the collapse label may
leak the removed author, so both fall back to anonymous copy; unbreakable
URLs and long tokens wrap via `break-words` + `min-w-0` on every flex column
instead of widening the row.
Rendering & styling
- Semantic tokens only: bg-muted / text-muted-foreground (avatar fallback,
secondary text, skeleton bars), bg-primary/5 + ring-primary/20 + text-primary
(pinned row, active upvote, sort chip, continue link), bg-destructive/5 +
ring-destructive/40 + text-destructive (failed post, error branch), border /
bg-border (thread line, chips), focus-visible:ring-2 ring-ring on every
control. No hex, no palette classes; "edited" is distinguished with italics
and weight rather than colour, so it survives a monochrome palette.
- Structure: nested `ul[role=list] > li > article`. Comments are content, not
a selection widget, so `article` (browse mode, every vote/reply/collapse
button a normal tab stop) is correct where `role=tree`/`treeitem` would
demand a roving-tabindex widget whose items are not meant to contain several
interactive controls. The list nesting is what conveys depth to a screen
reader; `role="list"` is written explicitly because `list-style: none`
strips list semantics in Safari. The `li → div → ul` chain keeps `ul` and
`li` in direct parent/child contact, so the ownership chain never breaks.
- Each row is `[rail | content]`: the rail holds the avatar and the thread
line, and its own width IS the indentation step — no per-level padding maths,
and the line automatically spans the full height of the comment plus its
subtree. Avatars are decorative (`alt=""` + aria-hidden) because the author
name is already text; `avatarUrl: null` falls back to initials so long pages
fire no image requests.
- The only animation is the loading pulse, and it carries
motion-reduce:animate-none — the state machine never waits on animation.
Customization levers
- Density: `gap-3` between rail and content plus `pb-4` per row reads as a
forum; drop to `gap-2` / `pb-2` and `size-6` avatars for a compact review
panel. The rail width (`w-8`) is the indent step — shrink it to tighten the
ladder on mobile.
- Affordance set: omit `score` from the data to remove voting entirely; omit
`onReply` for a read-only archive; omit `onSortChange` to hide the sort bar;
omit `onContinueThread` to keep deep threads inline.
- `maxDepth`: 2–3 for narrow sidebars, 5+ for full-page discussions. Only the
indentation is capped, so lowering it never hides content by itself.
- Metadata slots: `badge` is free-form ("OP", "Maintainer", "Staff") — style
it as a chip or swap in an icon; `pinned` can drive a border instead of a
tint; add fields (reactions, resolved flag) to the zod schema and render
them in the header row without touching the tree walk.
- Composer: swap the plain textarea for a rich editor or a mention-aware input
— the contract with the tree is only `onReply(id, body)` and the
consumer-owned `status: "posting" | "failed"` lifecycle.Concepts
- Subtree collapse, not row collapse — collapsing a comment hides every descendant at once and reports the total descendant count ("12 replies"), which is what makes a 200-comment issue skimmable; the comment itself stays readable so the reader keeps their place.
- Optimistic with takeover — a vote or collapse is stored as
{ value, base }against the data value it replaced. Whilebasematches the incoming data the reader's value wins; as soon as the consumer's data changes (accepted or rejected), the override is silently discarded. It is optimistic UI that cannot get stuck out of sync, and it needs no effect to reconcile. - Posting lifecycle owned by the consumer — the tree never invents a node.
onReplyhands over the trimmed body, the consumer inserts it withstatus: "posting", then clears the flag or flips it to"failed"; only the failed branch exposesonRetryPost. That keeps a real id, a real server error and a real retry in one place instead of hiding a fake success inside the component. - Depth cap ≠ truncation —
maxDepthstops the indent ladder so nesting can't eat a phone screen. Past it, the subtree either continues un-indented or is handed toonContinueThreadfor a permalink; nothing disappears just because the thread got deep. - Article over treeitem — a comment thread is content with several controls per node, so nested
articleelements insideul/likeep browse mode and normal tab order.role="tree"would impose a single-tab-stop roving-tabindex widget whosetreeitems are not meant to wrap multiple buttons — right for a file picker, wrong for a discussion. - Mouse-only twin — the vertical thread line does the same thing as the collapse button, so it is
tabIndex={-1}andaria-hidden: a big click target for pointers, invisible to assistive tech, no duplicate announcement.
Log Viewer
A runtime log console — level filters with counts, search highlighting with hit-to-hit jumps, tail-follow that pauses on manual scroll, and windowed rendering for huge buffers.
Chat Thread
A contract-driven conversation stream — sender grouping, day and unread dividers, streaming append, load-older with position kept, and a scroll that never gets stolen from the reader.