Skip to content
goodtabs UI
BrowseBrowse components

Comment thread

Comments with nested replies on a hairline rail.

npx shadcn@latest add https://ui.goodtabs.co/r/comment-thread.json

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

PropTypeDefaultDescription
commentsComment[]Each comment has id, author, time, body and optional replies.
maxDepthnumber2Levels that get an indent and rail. Deeper replies stay flush.

Source

comment-thread/comment-thread.tsx
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