# Comment Thread

> Nested comment thread with replies, likes and inline reply forms; new comments appear instantly.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/comment-thread
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/comment-thread.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/comment-thread-demo.tsx"
"use client"

import { CommentThread, type Comment } from "@/components/comment-thread"

const comments: Comment[] = [
  {
    id: "c1",
    author: "Maya Chen",
    time: "2h ago",
    likes: 12,
    body: "The new onboarding flow cut our drop-off by a third. Anyone else seeing this?",
    replies: [
      {
        id: "c1-1",
        author: "Jonas Weber",
        time: "1h ago",
        likes: 3,
        body: "Same here. The shorter form made the biggest difference.",
        replies: [{ id: "c1-1-1", author: "Maya Chen", time: "45m ago", likes: 1, body: "Agreed, we also removed the phone field." }],
      },
    ],
  },
  { id: "c2", author: "Priya Nair", time: "5h ago", likes: 4, body: "Would love a dark mode for the embedded widget too." },
]

export default function CommentThreadDemo() {
  return (
    <div className="w-full max-w-xl">
      <CommentThread comments={comments} currentUser="You" />
    </div>
  )
}
```

## Source

```tsx title="components/comment-thread.tsx"
"use client"

import * as React from "react"
import { Heart, Reply } from "lucide-react"

import { cn } from "@/lib/utils"

export interface Comment {
  id: string
  author: string
  body: string
  /** Pre-formatted, such as "2h ago" */
  time: string
  likes?: number
  replies?: Comment[]
}

export interface CommentThreadProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  comments: Comment[]
  currentUser?: string
  onAdd?: (comment: Comment, parentId: string | null) => void
}

const hues = [12, 38, 150, 200, 262, 320]
const hueOf = (name: string) => hues[[...name].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % hues.length]

function Avatar({ name }: { name: string }) {
  return (
    <span
      aria-hidden
      className="flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
      style={{ backgroundColor: `hsl(${hueOf(name)} 55% 45%)` }}
    >
      {name.slice(0, 1).toUpperCase()}
    </span>
  )
}

function ReplyForm({ onSubmit, onCancel, placeholder }: { onSubmit: (text: string) => void; onCancel?: () => void; placeholder: string }) {
  const [text, setText] = React.useState("")
  return (
    <form
      className="flex flex-col gap-2"
      onSubmit={(event) => {
        event.preventDefault()
        if (!text.trim()) return
        onSubmit(text.trim())
        setText("")
      }}
    >
      <textarea
        value={text}
        onChange={(event) => setText(event.target.value)}
        placeholder={placeholder}
        aria-label={placeholder}
        rows={2}
        autoFocus={!!onCancel}
        className="w-full resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/30"
      />
      <div className="flex justify-end gap-2">
        {onCancel && (
          <button type="button" onClick={onCancel} className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent">
            Cancel
          </button>
        )}
        <button
          type="submit"
          disabled={!text.trim()}
          className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-40"
        >
          Post
        </button>
      </div>
    </form>
  )
}

function CommentItem({
  comment,
  depth,
  onReply,
  onLike,
  liked,
}: {
  comment: Comment
  depth: number
  onReply: (parentId: string, text: string) => void
  onLike: (id: string) => void
  liked: Set<string>
}) {
  const [replying, setReplying] = React.useState(false)
  const isLiked = liked.has(comment.id)
  return (
    <li className="grid gap-3">
      <div className="flex gap-3">
        <Avatar name={comment.author} />
        <div className="min-w-0 flex-1">
          <div className="flex items-baseline gap-2 text-sm">
            <span className="font-semibold">{comment.author}</span>
            <span className="text-xs text-muted-foreground">{comment.time}</span>
          </div>
          <p className="mt-0.5 whitespace-pre-wrap text-sm leading-relaxed">{comment.body}</p>
          <div className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground">
            <button
              type="button"
              aria-pressed={isLiked}
              onClick={() => onLike(comment.id)}
              className={cn("flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-accent hover:text-foreground", isLiked && "text-rose-500 hover:text-rose-500")}
            >
              <Heart className={cn("size-3.5", isLiked && "fill-current")} />
              {(comment.likes ?? 0) + (isLiked ? 1 : 0)}
            </button>
            {depth < 3 && (
              <button
                type="button"
                aria-expanded={replying}
                onClick={() => setReplying((open) => !open)}
                className="flex items-center gap-1 rounded-md px-2 py-1 transition-colors hover:bg-accent hover:text-foreground"
              >
                <Reply className="size-3.5" /> Reply
              </button>
            )}
          </div>
          {replying && (
            <div className="mt-2">
              <ReplyForm
                placeholder={`Reply to ${comment.author}`}
                onCancel={() => setReplying(false)}
                onSubmit={(text) => {
                  onReply(comment.id, text)
                  setReplying(false)
                }}
              />
            </div>
          )}
        </div>
      </div>
      {comment.replies && comment.replies.length > 0 && (
        <ul className="ml-4 grid gap-4 border-l pl-4 sm:ml-5 sm:pl-5">
          {comment.replies.map((reply) => (
            <CommentItem key={reply.id} comment={reply} depth={depth + 1} onReply={onReply} onLike={onLike} liked={liked} />
          ))}
        </ul>
      )}
    </li>
  )
}

function insertReply(list: Comment[], parentId: string, reply: Comment): Comment[] {
  return list.map((c) =>
    c.id === parentId ? { ...c, replies: [...(c.replies ?? []), reply] } : c.replies ? { ...c, replies: insertReply(c.replies, parentId, reply) } : c,
  )
}

function countAll(list: Comment[]): number {
  return list.reduce((sum, c) => sum + 1 + countAll(c.replies ?? []), 0)
}

export function CommentThread({ comments: initial, currentUser = "You", onAdd, className, ...props }: CommentThreadProps) {
  const [comments, setComments] = React.useState(initial)
  const [liked, setLiked] = React.useState<Set<string>>(() => new Set())
  const nextId = React.useRef(0)

  const add = (parentId: string | null, text: string) => {
    const comment: Comment = { id: `local-${nextId.current++}`, author: currentUser, body: text, time: "just now", likes: 0 }
    setComments((prev) => (parentId ? insertReply(prev, parentId, comment) : [...prev, comment]))
    onAdd?.(comment, parentId)
  }

  const toggleLike = (id: string) =>
    setLiked((prev) => {
      const next = new Set(prev)
      if (!next.delete(id)) next.add(id)
      return next
    })

  return (
    <div {...props} className={cn("grid w-full gap-5", className)}>
      <h3 className="text-sm font-semibold">{countAll(comments)} comments</h3>
      <ReplyForm placeholder="Add a comment" onSubmit={(text) => add(null, text)} />
      <ul className="grid gap-5">
        {comments.map((comment) => (
          <CommentItem key={comment.id} comment={comment} depth={0} onReply={(parentId, text) => add(parentId, text)} onLike={toggleLike} liked={liked} />
        ))}
      </ul>
    </div>
  )
}
```
