caliper uidocs

Comment thread

Free

Comments and replies with a composer.

Installs components/ui/comment-thread.tsxDepends on @radix-ui/react-slot, lucide-reactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/comment-thread.json
Maya Chen

The empty state copy reads as an error. Can we make it feel like a next step instead?

Dev Patel

Agreed. I'll swap in the "Create your first project" action.

You

Works for me. Keep the illustration out of it so it stays calm.

Resolved · Dev Patel: Focus ring on the filter chips is clipped inside the card.· 1 reply

When to use #

Use it for

  • Discussion on a record.

Skip it when

  • Chat-like instant messages; use the chat thread.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/comment-thread.json

Adds its 5 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useRef, useState } from "react"
import { CommentThread, type ThreadComment } from "@/components/ui/comment-thread"

const initialComments: ThreadComment[] = [
  {
    id: "c1",
    author: { id: "maya", name: "Maya Chen" },
    body: "The empty state copy reads as an error. Can we make it feel like a next step instead?",
    createdAt: "2026-10-07T10:14:00Z",
    replies: [
      { id: "c1a", author: { id: "dev", name: "Dev Patel" }, body: "Agreed. I'll swap in the \"Create your first project\" action.", createdAt: "2026-10-07T10:31:00Z" },
      { id: "c1b", author: { id: "me", name: "You" }, body: "Works for me. Keep the illustration out of it so it stays calm.", createdAt: "2026-10-07T11:02:00Z" },
    ],
  },
  { id: "c2", author: { id: "dev", name: "Dev Patel" }, body: "Focus ring on the filter chips is clipped inside the card.", createdAt: "2026-10-06T16:40:00Z", resolved: true, replies: [{ id: "c2a", author: { id: "maya", name: "Maya Chen" }, body: "Fixed in the last push.", createdAt: "2026-10-06T17:05:00Z" }] },
];

export function CommentThreadDemo() {
  const [comments, setComments] = useState(initialComments);
  const counter = useRef(0);
  const iso = () => new Date(Date.UTC(2026, 9, 8, 9, 10 + counter.current++)).toISOString();
  const addReply = (rootId: string, text: string) =>
      setComments((list) => list.map((c) => (c.id === rootId ? { ...c, replies: [...(c.replies ?? []), { id: `r${counter.current++}`, author: { id: "me", name: "You" }, body: text, createdAt: iso() }] } : c)));
  const mapAll = (fn: (c: ThreadComment) => ThreadComment | null): ThreadComment[] =>
      comments.flatMap((root) => {
        const next = fn(root);
        return next ? [{ ...next, replies: next.replies?.flatMap((r) => { const x = fn(r); return x ? [x] : []; }) }] : [];
      });
  return (
    <CommentThread
              label="Design review comments"
              comments={comments}
              currentUserId="me"
              onReply={addReply}
              onEdit={(id, text) => setComments(mapAll((c) => (c.id === id ? { ...c, body: text, editedAt: iso() } : c)))}
              onDelete={(id) => setComments(mapAll((c) => (c.id === id ? null : c)))}
              onResolve={(id, resolved) => setComments((list) => list.map((c) => (c.id === id ? { ...c, resolved } : c)))}
            />
  );
}

API reference #

CommentThread

component
PropTypeDefaultDescription
label *string—
comments *readonly ThreadComment[]—
currentUserId *string—
onReply *(rootId: string, text: string, replyingTo: string) => void—Called with the root thread id and the comment being answered.
onEdit((id: string, text: string) => void)—
onDelete((id: string) => void)—
onResolve((rootId: string, resolved: boolean) => void)—
formatTime((iso: string) => string)defaultFormat
classNamestring—

Accessibility #

It exposes group and feed semantics, with aria-label, aria-expanded and aria-hidden kept in step with state.

No custom key handling; Tab and the native activation keys do the work.

Motion #

Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.