caliper uidocs

Chat thread

Free

A thread of chat messages grouped by sender and time.

Installs components/ui/chat-thread.tsxDepends on @radix-ui/react-slot, lucide-reactMotion honours reduced motion
pnpm dlx shadcn@latest add https://caliperui.site/r/chat-thread.json

Tuesday, October 6

You, 8:00 AM: Morning! Did the export finish?

· Read
Maya Chen

Maya 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?

· Read

Thursday, October 8

Maya Chen

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.

· Read
Enter to send · Shift+Enter for a new line

When to use #

Use it for

  • Messages between people.

Skip it when

  • An assistant conversation; use the message scroller.

Installation #

terminal
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.

example.tsx
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
PropTypeDefaultDescription
label *string—
messages *readonly ChatMessage[]—
people *Readonly<Record<string, ChatPerson>>—
currentUserId *string—
onSend *(text: string) => void—
onRetry((id: string) => void)—
onLoadEarlier(() => void)—
hasEarlierboolean—
typingreadonly string[][]Names of people currently typing.
loadingboolean—
disabledboolean—
timeZonestring"UTC"
classNamestring—

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.