Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowBigDown, ArrowBigUp, ChevronDown, ChevronRight, MessageSquare, Pin } from "lucide-react"
import { cn } from "@/lib/utils"
import type { Comment, CommentSort, CommentThreadStatus } from "./comment-thread.contract"

export interface CommentThreadProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Root comments, already ordered by the consumer (see `sort`). */
  comments: Comment[]
  status: CommentThreadStatus
  /**
   * How many rungs of indentation the ladder may grow, root = 0. Clamped to >= 1.
   * Replies deeper than this stop indenting; when `onContinueThread` is supplied

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/comment-thread.json

Prompt

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. While base matches 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. onReply hands over the trimmed body, the consumer inserts it with status: "posting", then clears the flag or flips it to "failed"; only the failed branch exposes onRetryPost. 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 ≠ truncationmaxDepth stops the indent ladder so nesting can't eat a phone screen. Past it, the subtree either continues un-indented or is handed to onContinueThread for 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 article elements inside ul/li keep browse mode and normal tab order. role="tree" would impose a single-tab-stop roving-tabindex widget whose treeitems 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} and aria-hidden: a big click target for pointers, invisible to assistive tech, no duplicate announcement.

On This Page