Media

Comment Thread

A nested comment thread with grid-rows collapse, a threading rail, vote controls, and reply actions.

Install

npx shadcn@latest add @paragon/comment-thread

comment-thread.tsx

"use client";

import * as React from "react";
import { ChevronDown, CornerDownRight } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Comment {
  id: string;
  author: string;
  time: string;
  body: string;
  votes: number;
  color?: string;
  replies?: Comment[];
}

export interface CommentThreadProps extends React.ComponentProps<"div"> {
  comments: Comment[];
  /** Renders collapse without the grid-rows animation. */
  static?: boolean;
}

/**
 * A nested comment thread. Each comment with replies collapses via a
 * grid-template-rows 0fr to 1fr transition (the sanctioned height animation),
 * driven by CSS so it retargets cleanly on rapid toggles; a threading rail
 * connects children to the parent. Votes are optimistic, keyed to a single
 * chosen direction. All controls are real buttons with focus rings; the
 * collapse toggle exposes aria-expanded.
 */
export function CommentThread({
  comments,
  static: isStatic = false,
  className,
  ...props
}: CommentThreadProps) {
  return (
    <div
      data-slot="comment-thread"
      className={cn("w-full max-w-md", className)}
      {...props}
    >
      <div className="flex flex-col gap-4">
        {comments.map((comment) => (
          <CommentNode key={comment.id} comment={comment} isStatic={isStatic} />
        ))}
      </div>
    </div>
  );
}

function CommentNode({
  comment,
  isStatic,
}: {
  comment: Comment;
  isStatic: boolean;
}) {
  const [open, setOpen] = React.useState(true);
  const [vote, setVote] = React.useState<0 | 1 | -1>(0);
  const hasReplies = !!comment.replies?.length;
  const count = comment.votes + vote;

  return (
    <div className="flex gap-3">
      <div className="flex flex-col items-center gap-1.5">
        <span
          aria-hidden
          className="size-7 shrink-0 rounded-full"
          style={{ background: comment.color ?? "var(--muted-foreground)" }}
        />
        {hasReplies && open && (
          <span
            aria-hidden
            className="w-px flex-1 bg-border"
          />
        )}
      </div>

      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2 text-xs">
          <span className="font-semibold text-foreground">{comment.author}</span>
          <time className="tabular-nums text-muted-foreground">
            {comment.time}
          </time>
        </div>
        <p className="mt-0.5 text-sm text-foreground/90">{comment.body}</p>

        <div className="mt-1.5 flex items-center gap-1">
          <VoteControl vote={vote} count={count} onVote={setVote} />
          <button
            type="button"
            className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <CornerDownRight className="size-3.5" />
            Reply
          </button>
          {hasReplies && (
            <button
              type="button"
              aria-expanded={open}
              onClick={() => setOpen((o) => !o)}
              className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              <ChevronDown
                className={cn(
                  "size-3.5 transition-transform duration-200 ease-out",
                  !open && "-rotate-90",
                )}
              />
              {open ? "Hide" : "Show"} {comment.replies!.length}{" "}
              {comment.replies!.length === 1 ? "reply" : "replies"}
            </button>
          )}
        </div>

        {hasReplies && (
          <div
            className="grid"
            style={{
              gridTemplateRows: open ? "1fr" : "0fr",
              transition: isStatic
                ? undefined
                : "grid-template-rows 300ms var(--ease-in-out)",
            }}
          >
            <div className="overflow-hidden">
              <div className="mt-3 flex flex-col gap-4">
                {comment.replies!.map((reply) => (
                  <CommentNode
                    key={reply.id}
                    comment={reply}
                    isStatic={isStatic}
                  />
                ))}
              </div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function VoteControl({
  vote,
  count,
  onVote,
}: {
  vote: 0 | 1 | -1;
  count: number;
  onVote: (v: 0 | 1 | -1) => void;
}) {
  return (
    <div className="inline-flex items-center rounded-md border border-border">
      <button
        type="button"
        aria-label="Upvote"
        aria-pressed={vote === 1}
        onClick={() => onVote(vote === 1 ? 0 : 1)}
        className={cn(
          "flex h-7 w-7 items-center justify-center rounded-l-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          vote === 1 && "text-success",
        )}
      >
        <span aria-hidden className="text-sm leading-none">▲</span>
      </button>
      <span className="min-w-6 px-1 text-center text-xs font-medium tabular-nums">
        {count}
      </span>
      <button
        type="button"
        aria-label="Downvote"
        aria-pressed={vote === -1}
        onClick={() => onVote(vote === -1 ? 0 : -1)}
        className={cn(
          "flex h-7 w-7 items-center justify-center rounded-r-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          vote === -1 && "text-destructive",
        )}
      >
        <span aria-hidden className="text-sm leading-none">▼</span>
      </button>
    </div>
  );
}