A nested comment thread with grid-rows collapse, a threading rail, vote controls, and reply actions.
npx shadcn@latest add @paragon/comment-thread"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>
);
}