A whole conversation with an assistant: the messages, a box to write in, and the stop and jump-to-latest controls. It shows what you give it and reports what the person does; it never calls a model.
Bring your own model.
These components only show what you give them, in one message shape, and report what the person does. They never call a model or a server, so they work with any backend. They are built for how assistive technology handles streaming text, tool steps and approvals.
Ask anything to get started.
Enter to send, Shift+Enter for a new line
import { useEffect, useRef, useState } from "react";
import { Chat, appendText, finishMessage, type ChatMessage } from "@rdloom/react";
const reply =
"Happy to help. **Rdloom** components are spec-driven and accessible, so you copy the source into your project and own it.\n\nWant a tour of the data grid next?";
type Status = "ready" | "submitted" | "streaming";
// The chat only shows what you pass in. Here a timer plays the part of a model.
export default function ChatBasicExample() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<Status>("ready");
const timer = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => () => clearInterval(timer.current), []);
const send = (text: string) => {
const id = Date.now();
setMessages((m) => [...m, { id: `u${id}`, role: "user", parts: [{ type: "text", text }] }]);
setStatus("submitted");
let at = 0;
timer.current = setInterval(() => {
const chunk = reply.slice(at, at + 4);
at += 4;
setStatus("streaming");
setMessages((m) => {
const last = m[m.length - 1];
const current: ChatMessage = last?.role === "assistant" ? last : { id: `a${id}`, role: "assistant", parts: [] };
const next = appendText(current, chunk);
return last?.role === "assistant" ? [...m.slice(0, -1), next] : [...m, next];
});
if (at >= reply.length) {
clearInterval(timer.current);
setMessages((m) => [...m.slice(0, -1), finishMessage(m[m.length - 1])]);
setStatus("ready");
}
}, 60);
};
const stop = () => {
clearInterval(timer.current);
setMessages((m) => (m.length ? [...m.slice(0, -1), finishMessage(m[m.length - 1], "stopped")] : m));
setStatus("ready");
};
return (
<div className="h-[28rem] w-[40rem] max-w-full overflow-hidden rounded-xl border border-[var(--rd-color-border-default)]">
<Chat label="Assistant" messages={messages} status={status} onSend={send} onStop={stop} />
</div>
);
}Installation
npx rdloom add chatCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/chat.jsonWorks, but without upgrade tracking.
Usage
import { Chat, appendText, finishMessage, type ChatMessage } from "@rdloom/react";
<div className="h-[28rem] w-[40rem] max-w-full overflow-hidden rounded-xl border border-[var(--rd-color-border-default)]">
<Chat label="Assistant" messages={messages} status={status} onSend={send} onStop={stop} />
</div>With tools
Ask anything to get started.
Enter to send, Shift+Enter for a new line
import { useEffect, useRef, useState } from "react";
import { Chat, finishMessage, updateTool, type ChatMessage } from "@rdloom/react";
const now = () => new Date().toISOString();
// A scripted run: a tool works, a second one asks for approval, and then a chart and a table arrive.
export default function ChatWithToolsExample() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<"ready" | "submitted" | "streaming">("ready");
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const later = (ms: number, run: () => void) => void timers.current.push(window.setTimeout(run, ms));
const patch = (change: (m: ChatMessage) => ChatMessage) => setMessages((list) => list.map((m, i) => (i === list.length - 1 ? change(m) : m)));
const send = (text: string) => {
setMessages((m) => [...m, { id: `u${m.length}`, role: "user", parts: [{ type: "text", text }] }]);
setStatus("submitted");
later(500, () => {
setStatus("streaming");
setMessages((m) => [
...m,
{ id: `a${m.length}`, role: "assistant", status: "streaming", parts: [{ type: "tool", id: "q", name: "query_sales", title: "Searching your sales data", state: "running", startedAt: now() }] },
]);
});
later(1700, () => patch((m) => updateTool(m, "q", { state: "done", endedAt: now(), output: { weeks: 4 } })));
later(1800, () =>
patch((m) => ({
...m,
parts: [
...m.parts,
{ type: "tool", id: "s", name: "email_report", title: "Email the report to finance", state: "awaiting-approval", approval: { summary: "Email this report to finance@example.com", risk: "medium", reversible: false } },
],
})),
);
};
const finish = (sent: boolean) => {
later(700, () => {
patch((m) => (sent ? updateTool(m, "s", { state: "done" }) : m));
patch((m) =>
finishMessage({
...m,
parts: [
...m.parts,
{ type: "text", text: sent ? "Sent. Here is last month at a glance:" : "Okay, I won't send it. Here is last month at a glance:" },
{
type: "artifact",
kind: "chart",
title: "Revenue by week",
summary: "Revenue rose every week, from $9.2K to $15.8K.",
data: { labels: ["Wk 1", "Wk 2", "Wk 3", "Wk 4"], series: [{ name: "Revenue", values: [9200, 11400, 13100, 15800] }], unit: "$" },
},
],
}),
);
setStatus("ready");
});
};
return (
<div className="h-[32rem] w-[44rem] max-w-full overflow-hidden rounded-xl border border-[var(--rd-color-border-default)]">
<Chat
label="Assistant"
messages={messages}
status={status}
suggestions={["Show me sales from last month"]}
onSend={send}
onApprove={(id) => {
patch((m) => updateTool(m, id, { state: "approved" }));
finish(true);
}}
onDeny={(id) => {
patch((m) => updateTool(m, id, { state: "denied" }));
finish(false);
}}
/>
</div>
);
}Suggestions
Ask anything to get started.
Enter to send, Shift+Enter for a new line
import { useState } from "react";
import { Chat, type ChatMessage } from "@rdloom/react";
// An empty chat can offer ways to start. Choosing one sends it like any message.
export default function ChatSuggestionsExample() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
return (
<div className="h-[24rem] w-[40rem] max-w-full overflow-hidden rounded-xl border border-[var(--rd-color-border-default)]">
<Chat
label="Assistant"
messages={messages}
suggestions={["Summarise last month's sales", "Which customers are overdue?", "Draft a reminder email"]}
onSend={(text) =>
setMessages((m) => [
...m,
{ id: `u${m.length}`, role: "user", parts: [{ type: "text", text }] },
{ id: `a${m.length}`, role: "assistant", parts: [{ type: "text", text: "This demo doesn't call a model: you decide what happens in onSend." }] },
])
}
/>
</div>
);
}With attachments
Ask anything to get started.
Enter to send, Shift+Enter for a new line
import { useEffect, useRef, useState } from "react";
import { Chat, GlobeIcon, ImageIcon, fileToAttachment, releaseAttachment, type ChatMessage, type PromptAttachment } from "@rdloom/react";
// A chat with the + menu, file previews and a microphone. Sent files become `file` parts of the message.
// Nothing here uploads anywhere: that is up to your onSend.
export default function ChatWithAttachmentsExample() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [files, setFiles] = useState<PromptAttachment[]>([]);
const [listening, setListening] = useState(false);
const sent = useRef<PromptAttachment[]>([]);
const latest = useRef(files);
latest.current = files;
useEffect(
() => () => {
latest.current.forEach(releaseAttachment);
sent.current.forEach(releaseAttachment);
},
[],
);
return (
<div className="h-[30rem] w-[44rem] max-w-full overflow-hidden rounded-xl border border-[var(--rd-color-border-default)]">
<Chat
label="Assistant"
placeholder="Ask anything"
messages={messages}
attachments={files}
accept="image/*,.pdf,.txt"
onAttach={(chosen) => setFiles((list) => [...list, ...chosen.map(fileToAttachment)])}
onRemoveAttachment={(id) =>
setFiles((list) => {
list.filter((a) => a.id === id).forEach(releaseAttachment);
return list.filter((a) => a.id !== id);
})
}
actions={[
{ id: "image", label: "Create image", description: "Visualize anything", icon: <ImageIcon className="size-5" />, onSelect: () => {} },
{ id: "search", label: "Web search", description: "Find real-time news and info", icon: <GlobeIcon className="size-5" />, onSelect: () => {} },
]}
onVoice={() => setListening((v) => !v)}
isListening={listening}
onSend={(text, attached = []) => {
sent.current.push(...attached); // the message keeps showing the pictures, so their previews stay alive
setFiles([]);
setMessages((m) => [
...m,
{
id: `u${m.length}`,
role: "user",
parts: [
...attached.map((a) => ({ type: "file" as const, name: a.name, mediaType: a.mediaType, url: a.url, size: a.size })),
...(text ? [{ type: "text" as const, text }] : []),
],
},
{ id: `a${m.length}`, role: "assistant", parts: [{ type: "text", text: "Got it. This demo doesn't call a model: you decide what happens in onSend." }] },
]);
}}
/>
</div>
);
}API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
messagesrequiredThe conversation so far. | ChatMessage[] | none |
onSendrequiredCalled with the text and any attachments when the person sends a message or picks a suggestion. | (text: string, attachments?: PromptAttachment[]) => void | none |
onStopCalled when the person presses Stop while a reply is on its way. | () => void | none |
onApproveCalled with a tool's id when the person approves it. | (toolId: string) => void | none |
onDenyCalled with a tool's id when the person declines it. | (toolId: string) => void | none |
onRetryShows Try again on the last message when it failed. | () => void | none |
statusWhere the conversation is: ready for input, waiting for the first words, streaming, or failed. | "ready" | "submitted" | "streaming" | "error" | "ready" |
labelAccessible name of the chat. | string | "Chat" |
placeholderHint in the message box. | string | none |
suggestionsPrompts to offer when the chat is empty. Choosing one sends it. | string[] | none |
emptyStateReplaces the default empty message. | node | none |
headingLevelLevel of headings inside replies, 2 to 6. | number | none |
attachmentsFiles added to the message being written. You own the list: add in onAttach, remove in onRemoveAttachment, clear it in onSend. | PromptAttachment[] | none |
onAttachCalled with the chosen files. Providing it adds "Add photos & files" to the + menu and opens the file picker. Use fileToAttachment() to make attachments. | (files: File[]) => void | none |
onRemoveAttachmentCalled with an attachment's id when the person removes it. Without it the attachments have no remove button. | (id: string) => void | none |
acceptWhich files the picker offers, e.g. "image/*,.pdf". | string | none |
actionsExtra entries for the + menu, such as Create image or Web search. Each has a label, an optional description and icon, and what to do when chosen. | PromptAction[] | none |
onVoiceShows a microphone button and calls this when it is pressed. Start or stop dictation yourself. | () => void | none |
isListeningDictation is on: the microphone button shows as pressed and its name becomes Stop dictation. | boolean | false |
endContentYour own controls on the right, before the microphone, for example a Think toggle. | node | none |
Accessibility
Role log, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab: moves from the message list (focusable, so it can scroll with arrow keys) to the message box
- Enter: sends; Shift+Enter: new line
- Arrow keys / Page Up / Page Down: scroll the messages when the list has focus
Screen readers announce
- "Chat, region", then "Chat messages, log"
- After sending: "Assistant is responding", later "Response complete"
- A tool needing approval: "Approval needed: <what>"
What your code must do
- The message list is a log that is NOT live: streamed text would be read token by token
- One polite status line says "Assistant is responding" and "Response complete"; tools announce their own changes
- The list follows new text only while the reader is at the bottom; scrolling up stops it and shows Jump to latest
- Only the newest unanswered approval takes focus
- The message list is keyboard-scrollable (focusable) and has a visible focus ring
- Sending always returns the view to the latest message
Guidelines
Use it when
- An assistant, copilot or agent conversation
- Support chat with an AI
Avoid it when
- Chat between people with presence and typing: build on Message and PromptInput
Don't
- Making the whole list aria-live
- Auto-scrolling while someone is reading earlier messages
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-action-primary--rd-color-border-default--rd-color-border-strong--rd-color-focus-ring--rd-color-surface-default--rd-color-surface-raised--rd-color-surface-subtle--rd-color-text-default--rd-color-text-muted--rd-elevation-floating--rd-elevation-raised