Agreed. I'll swap in the "Create your first project" action.
Works for me. Keep the illustration out of it so it stays calm.
Comments and replies with a composer.
pnpm dlx shadcn@latest add https://caliperui.site/r/comment-thread.jsonAgreed. I'll swap in the "Create your first project" action.
Works for me. Keep the illustration out of it so it stays calm.
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.
This is the code behind the preview above.
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)))} /> ); }
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.
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.
The empty state copy reads as an error. Can we make it feel like a next step instead?