Comment thread
Comments with nested replies on a hairline rail.
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/comment-thread
Maya Chen2h ago
The focus ring clips inside the card.
Dev Patel1h ago
Fixed with an inset ring.
A comment and a reply, with initials for avatars.
Deep thread
Maya Chen3h ago
Can the tab list wrap on small screens? It scrolls sideways at 390px right now.
Dev Patel2h ago
Scrolling is on purpose, but I will add a fade at the edge so it reads as scrollable.
Maya Chen2h ago
Fine by me. Keep the active tab in view when it loads.
Dev Patel1h ago
Done, it scrolls into view on mount.
Sam Okafor1h ago
Agreed with the fade.
Lena Brandt35m ago
Approving once the contrast check passes.
Four levels. The rail stops at the max depth so text keeps its width.
Long text
Bartholomew Featherstonehaughjust now
Try pnpm dlx shadcn@latest add https://example.com/registry/very-long-component-name.json and report back.
Jo1m ago
Worked.
Long names and unbroken strings wrap inside the column.
Usage
import { CommentThread } from "@/components/ui/comment-thread"
<CommentThread comments={[{ id: "1", author: "Maya Chen", time: "2h ago", body: "Looks good." }]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| comments | Comment[] | Each comment has id, author, time, body and optional replies. | |
| maxDepth | number | 2 | Levels that get an indent and rail. Deeper replies stay flush. |
Source
import * as React from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
type Comment = {
id: string;
author: string;
/** Display text for when it was posted, for example "2h ago". */
time: string;
body: string;
replies?: Comment[];
};
type CommentThreadProps = Omit<React.ComponentProps<"ul">, "children"> & {
comments: Comment[];
/** Replies nest this deep, then stay at the same indent. */
maxDepth?: number;
};
function initials(name: string) {
return name
.split(" ")
.map((part) => Array.from(part)[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
function Item({ comment, depth, maxDepth }: { comment: Comment; depth: number; maxDepth: number }) {
const replies = comment.replies ?? [];
return (
<li className="flex flex-col gap-3">
<article className="flex gap-3" aria-label={`Comment by ${comment.author}`}>
<Avatar size="sm" aria-hidden="true" className="mt-0.5">
<AvatarFallback className="bg-muted text-xs text-foreground">{initials(comment.author)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="m-0 flex items-baseline gap-2 text-sm">
<span className="font-medium text-foreground">{comment.author}</span>
<span className="font-mono text-xs text-muted-foreground">{comment.time}</span>
</p>
<p className="m-0 mt-0.5 text-sm [overflow-wrap:anywhere] text-foreground/90">{comment.body}</p>
</div>
</article>
{replies.length > 0 && (
<ul
aria-label={`Replies to ${comment.author}`}
className={cn(
"m-0 flex list-none flex-col gap-3 p-0",
depth < maxDepth ? "ml-3 border-l border-border pl-5" : "",
)}
>
{replies.map((reply) => (
<Item key={reply.id} comment={reply} depth={depth + 1} maxDepth={maxDepth} />
))}
</ul>
)}
</li>
);
}
/**
* A comment thread with nested replies. Each level is a real nested list with a hairline
* rail on the left, and the rail stops at `maxDepth` so deep threads do not squeeze the text.
*/
export function CommentThread({ comments, maxDepth = 2, className, ...props }: CommentThreadProps) {
return (
<ul aria-label="Comments" className={cn("m-0 flex w-full list-none flex-col gap-4 p-0", className)} {...props}>
{comments.map((comment) => (
<Item key={comment.id} comment={comment} depth={0} maxDepth={maxDepth} />
))}
</ul>
);
}
For AI agents
Hand your agent this page, the markdown at /components/comment-thread/prompt.md, or the Copy AI prompt button above.
When to use
- Review notes on a design, pull request or document
- A discussion under a release or changelog entry
When not to use
- A composer or reply box: pair it with composer
- Live chat, where order and scroll anchoring matter
Agent notes
- Display only: no reply, edit or like handlers
- Time is a string you format; the component does not parse dates
- Avatars are initials on a muted tile, so there is no colour per person
- Nested lists carry an aria-label naming the author being replied to