Skip to content

Chat

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.

AIv0.1.0experimentalWCAG 2.2 AAView spec

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 chat

Copies 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.json

Works, 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.

PropTypeDefault
messagesrequired

The conversation so far.

ChatMessage[]none
onSendrequired

Called with the text and any attachments when the person sends a message or picks a suggestion.

(text: string, attachments?: PromptAttachment[]) => voidnone
onStop

Called when the person presses Stop while a reply is on its way.

() => voidnone
onApprove

Called with a tool's id when the person approves it.

(toolId: string) => voidnone
onDeny

Called with a tool's id when the person declines it.

(toolId: string) => voidnone
onRetry

Shows Try again on the last message when it failed.

() => voidnone
status

Where the conversation is: ready for input, waiting for the first words, streaming, or failed.

"ready" | "submitted" | "streaming" | "error""ready"
label

Accessible name of the chat.

string"Chat"
placeholder

Hint in the message box.

stringnone
suggestions

Prompts to offer when the chat is empty. Choosing one sends it.

string[]none
emptyState

Replaces the default empty message.

nodenone
headingLevel

Level of headings inside replies, 2 to 6.

numbernone
attachments

Files added to the message being written. You own the list: add in onAttach, remove in onRemoveAttachment, clear it in onSend.

PromptAttachment[]none
onAttach

Called 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[]) => voidnone
onRemoveAttachment

Called with an attachment's id when the person removes it. Without it the attachments have no remove button.

(id: string) => voidnone
accept

Which files the picker offers, e.g. "image/*,.pdf".

stringnone
actions

Extra 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
onVoice

Shows a microphone button and calls this when it is pressed. Start or stop dictation yourself.

() => voidnone
isListening

Dictation is on: the microphone button shows as pressed and its name becomes Stop dictation.

booleanfalse
endContent

Your own controls on the right, before the microphone, for example a Think toggle.

nodenone

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