Chat thread
FreeA thread of chat messages grouped by sender and time.
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-thread.jsonTuesday, October 6
You, 8:00 AM: Morning! Did the export finish?
· ReadMaya Chen, 8:03 AM: Yes, 4,210 rows. I left the CSV in the shared folder.
Maya Chen, 8:06 AM: Perfect, thank you.
You, 8:09 AM: Do you want the PDF summary too?
· ReadThursday, October 8
Maya Chen, 9:02 AM: Quick check before the review: are we shipping the table update today?
Maya Chen, 9:02 AM: I want to tell the customer success team.
You, 9:05 AM: Yes, it's merged. Waiting on one last visual pass.
· ReadWhen to use #
Use it for
- Messages between people.
Skip it when
- An assistant conversation; use the message scroller.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-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.
import { useRef, useState } from "react" import { ChatThread, type ChatMessage } from "@/components/ui/chat-thread" const people = { me: { name: "You" }, maya: { name: "Maya Chen" }, dev: { name: "Dev Patel" }, }; const history = (n: number): ChatMessage[] => Array.from({ length: n }, (_, i) => ({ id: `old-${i}`, senderId: i % 3 === 0 ? "me" : "maya", text: ["Morning! Did the export finish?", "Yes, 4,210 rows. I left the CSV in the shared folder.", "Perfect, thank you.", "Do you want the PDF summary too?"][i % 4], createdAt: new Date(Date.UTC(2026, 9, 6, 8, i * 3)).toISOString(), status: i % 3 === 0 ? ("read" as const) : undefined, })); const seed: ChatMessage[] = [ ...history(4), { id: "m1", senderId: "maya", text: "Quick check before the review: are we shipping the table update today?", createdAt: "2026-10-08T09:02:00Z" }, { id: "m2", senderId: "maya", text: "I want to tell the customer success team.", createdAt: "2026-10-08T09:02:40Z" }, { id: "m3", senderId: "me", text: "Yes, it's merged. Waiting on one last visual pass.", createdAt: "2026-10-08T09:05:00Z", status: "read" }, ]; export function ChatThreadDemo() { const [messages, setMessages] = useState(seed); const [earlier, setEarlier] = useState(true); const [typing, setTyping] = useState<string[]>([]); const counter = useRef(0); const timers = useRef<ReturnType<typeof setTimeout>[]>([]); const iso = () => new Date(Date.UTC(2026, 9, 8, 9, 10 + counter.current++)).toISOString(); const send = (text: string) => { const id = `s${counter.current++}`; setMessages((m) => [...m, { id, senderId: "me", text, createdAt: iso(), status: "sending" }]); timers.current.push( setTimeout(() => setMessages((m) => m.map((x) => (x.id === id ? { ...x, status: /fail/i.test(text) ? "failed" : "sent" } : x))), 700), setTimeout(() => setTyping(["Maya"]), 1200), setTimeout(() => { setTyping([]); setMessages((m) => [ ...m.map((x) => (x.id === id && x.status === "sent" ? { ...x, status: "read" as const } : x)), { id: `r${counter.current++}`, senderId: "maya", text: "Got it, thanks! I'll post the update now.", createdAt: iso() }, ]); }, 3200), ); }; return ( <ChatThread className="h-[28rem]" label="Chat with Maya" messages={messages} people={people} currentUserId="me" typing={typing} hasEarlier={earlier} onLoadEarlier={() => { setMessages((m) => [...history(3).map((x, i) => ({ ...x, id: `e${i}-${m.length}`, createdAt: new Date(Date.UTC(2026, 9, 5, 15, i * 2)).toISOString() })), ...m]); setEarlier(false); }} onSend={send} onRetry={(id) => { setMessages((m) => m.map((x) => (x.id === id ? { ...x, status: "sending" } : x))); timers.current.push(setTimeout(() => setMessages((m) => m.map((x) => (x.id === id ? { ...x, status: "sent" } : x))), 700)); }} /> ); }
API reference #
ChatThread
component
Accessibility #
It exposes log and status semantics, with aria-label, aria-live, aria-relevant, aria-busy and aria-hidden kept in step with state. Changes that matter are announced through a live region.
No custom key handling; Tab and the native activation keys do the work.
Motion #
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.