Navigation
Messages
A named message history that politely announces new messages appended at the end.
When to use it: Use it for an ongoing chat or messaging history; use Feed for scroll-loaded articles.
Example
import { useState, type FormEvent } from "react";
import { Button, Label, Messages, TextArea, TextField } from "@comp0/react";
type Message = {
id: string;
author: "Ada" | "You";
body: string;
sentAt: string;
time: string;
};
const initialMessages: Message[] = [
{
id: "message-1",
author: "Ada",
body: "Could you send the revised outline?",
sentAt: "2026-07-13T10:40:00+02:00",
time: "10:40",
},
{
id: "message-2",
author: "You",
body: "Absolutely — I’ll share it this afternoon.",
sentAt: "2026-07-13T10:42:00+02:00",
time: "10:42",
},
];
export function Example() {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
function sendMessage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = draft.trim();
if (!body) return;
setMessages((current) => [
...current,
{
id: `message-${current.length + 1}`,
author: "You",
body,
sentAt: new Date().toISOString(),
time: "Now",
},
]);
setDraft("");
}
return (
<section aria-labelledby="conversation-title" className="flex w-full max-w-sm flex-col gap-3">
<h2
id="conversation-title"
className="text-base font-medium text-zinc-900 sm:text-sm dark:text-zinc-100"
>
Conversation with Ada
</h2>
<Messages aria-labelledby="conversation-title" className="flex flex-col gap-2">
{messages.map((message) => (
<article
key={message.id}
aria-labelledby={`${message.id}-author`}
data-direction={message.author === "You" ? "outgoing" : "incoming"}
className="max-w-[85%] rounded-lg px-3 py-2 text-base data-[direction=incoming]:self-start data-[direction=incoming]:bg-zinc-100 data-[direction=incoming]:text-zinc-900 data-[direction=outgoing]:self-end data-[direction=outgoing]:bg-teal-600 data-[direction=outgoing]:text-white sm:text-sm dark:data-[direction=incoming]:bg-zinc-800 dark:data-[direction=incoming]:text-zinc-100"
>
<header className="flex items-baseline justify-between gap-3 text-sm opacity-75">
<strong id={`${message.id}-author`} className="font-medium">
{message.author}
</strong>
<time dateTime={message.sentAt}>{message.time}</time>
</header>
<p className="mt-1">{message.body}</p>
</article>
))}
</Messages>
<form className="flex items-end gap-2" onSubmit={sendMessage}>
<TextField
as="div"
value={draft}
onChange={setDraft}
className="flex min-w-0 flex-1 flex-col gap-1.5"
>
<Label className="text-base font-medium text-zinc-900 sm:text-sm dark:text-zinc-100">
Message
</Label>
<TextArea
className="min-h-11 w-full resize-none rounded border border-zinc-950/10 bg-white px-3 py-2.5 text-base text-zinc-950 outline-teal-600 focus-visible:outline-2 sm:py-2 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:outline-teal-400"
name="message"
rows={1}
/>
</TextField>
<Button
type="submit"
disabled={!draft.trim()}
className="rounded bg-teal-600 px-3 py-2.5 text-base text-white outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:py-2 sm:text-sm dark:bg-teal-500 dark:text-zinc-950 dark:outline-teal-400"
>
Send
</Button>
</form>
</section>
);
}Assistant chat
Messages, Message, Reasoning, Suggestions, Composer, Feedback, and CopyButton working together on a fake streamed reply.
import { useEffect, useState } from "react";
import {
Composer,
ComposerInput,
ComposerSend,
ComposerStop,
CopyButton,
Feedback,
FeedbackButton,
Message,
MessageAuthor,
MessageContent,
Messages,
Reasoning,
ReasoningContent,
ReasoningSummary,
Suggestion,
Suggestions,
} from "@comp0/react";
type Rating = "good" | "bad" | "";
type Turn = {
id: number;
from: "user" | "assistant";
text: string;
streaming: boolean;
thinking: boolean;
rating: Rating;
};
type Reply = { id: number; words: string[] };
function replyTo(prompt: string) {
return `Here is a short answer to "${prompt}". Every control in this chat is a real button or form field, so it works by keyboard and with a screen reader, even while this reply is still streaming in.`;
}
export function Example() {
const [turns, setTurns] = useState<Turn[]>([]);
const [reply, setReply] = useState<Reply | null>(null);
const generating = reply !== null;
useEffect(() => {
if (!reply) return;
const assistant = turns.find((turn) => turn.id === reply.id);
if (!assistant) return;
const shown = assistant.text === "" ? 0 : assistant.text.split(" ").length;
const done = shown >= reply.words.length;
const delay = assistant.thinking ? 1200 : 60;
const timer = setTimeout(() => {
setTurns((current) =>
current.map((turn) => {
if (turn.id !== reply.id) return turn;
if (turn.thinking) return { ...turn, thinking: false };
if (done) return { ...turn, streaming: false };
return { ...turn, text: reply.words.slice(0, shown + 1).join(" ") };
}),
);
if (!assistant.thinking && done) setReply(null);
}, delay);
return () => clearTimeout(timer);
}, [reply, turns]);
const send = (text: string) => {
const id = turns.length + 1;
setTurns((current) => [
...current,
{ id, from: "user", text, streaming: false, thinking: false, rating: "" },
{ id: id + 1, from: "assistant", text: "", streaming: true, thinking: true, rating: "" },
]);
setReply({ id: id + 1, words: replyTo(text).split(" ") });
};
const stop = () => {
setTurns((current) => current.map((turn) => ({ ...turn, streaming: false, thinking: false })));
setReply(null);
};
const rate = (id: number, rating: Rating) => {
setTurns((current) => current.map((turn) => (turn.id === id ? { ...turn, rating } : turn)));
};
return (
<section aria-labelledby="assistant-title" className="flex w-full max-w-md flex-col gap-3">
<h2 id="assistant-title" className="font-semibold text-zinc-950 dark:text-white">
Audit assistant
</h2>
<Messages
aria-labelledby="assistant-title"
busy={generating}
className="flex min-h-48 flex-col gap-3 rounded-xl border border-zinc-950/10 bg-white p-4 dark:border-white/10 dark:bg-zinc-900"
>
{turns.map((turn) => (
<Message
key={turn.id}
from={turn.from}
status={turn.streaming ? "streaming" : "complete"}
className="flex max-w-[90%] flex-col gap-2 rounded-lg px-3 py-2 text-base data-[from=assistant]:self-start data-[from=assistant]:bg-zinc-100 data-[from=assistant]:text-zinc-900 data-[from=user]:self-end data-[from=user]:bg-teal-700 data-[from=user]:text-white sm:text-sm dark:data-[from=assistant]:bg-zinc-800 dark:data-[from=assistant]:text-zinc-100"
>
<MessageAuthor className="sr-only">
{turn.from === "user" ? "You" : "Assistant"}
</MessageAuthor>
{turn.from === "assistant" && (
<Reasoning busy={turn.thinking}>
<ReasoningSummary className="cursor-pointer text-sm opacity-80">
{turn.thinking ? "Thinking…" : "Thought for 1 second"}
</ReasoningSummary>
<ReasoningContent className="pt-1 text-sm opacity-80">
Matched the question against the audit results.
</ReasoningContent>
</Reasoning>
)}
{turn.text !== "" && <MessageContent>{turn.text}</MessageContent>}
{turn.from === "assistant" && !turn.streaming && turn.text !== "" && (
<div className="flex items-center gap-2">
<Feedback
aria-label="Rate this response"
value={turn.rating}
onChange={(rating) => rate(turn.id, rating)}
className="flex gap-2"
>
<FeedbackButton
value="good"
className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400 data-selected:border-teal-600"
>
Good response
</FeedbackButton>
<FeedbackButton
value="bad"
className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400 data-selected:border-teal-600"
>
Bad response
</FeedbackButton>
</Feedback>
<CopyButton
value={turn.text}
className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400"
>
Copy response
</CopyButton>
</div>
)}
</Message>
))}
</Messages>
<Suggestions
aria-label="Suggested questions"
onSend={send}
disabled={generating}
className="flex flex-wrap gap-2"
>
<Suggestion
value="Which checks failed?"
className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400"
>
Which checks failed?
</Suggestion>
<Suggestion
value="Summarize the audit"
className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400"
>
Summarize the audit
</Suggestion>
</Suggestions>
<Composer
onSend={send}
onStop={stop}
generating={generating}
className="flex items-end gap-2"
>
<ComposerInput
aria-label="Message"
placeholder="Ask about the audit"
className="min-h-11 min-w-0 flex-1 resize-none rounded border border-zinc-950/10 bg-white px-3 py-2.5 text-base text-zinc-950 outline-teal-600 focus-visible:outline-2 sm:py-2 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:outline-teal-400"
/>
<ComposerSend className="rounded bg-teal-600 px-3 py-2.5 text-base text-white outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:py-2 sm:text-sm dark:bg-teal-500 dark:text-zinc-950 dark:outline-teal-400">
Send
</ComposerSend>
<ComposerStop className="rounded border border-zinc-950/10 bg-white px-3 py-2 text-base text-zinc-900 outline-teal-600 focus-visible:outline-2 disabled:opacity-50 sm:text-sm dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:outline-teal-400">
Stop
</ComposerStop>
</Composer>
</section>
);
}Streaming response
Keep visible response text moving while aria-busy defers the live log until the complete or interrupted response is ready to announce.
import { useEffect, useState } from "react";
import { Button, Messages, Status } from "@comp0/react";
const responseWords =
"The accessibility review is complete. Keyboard navigation, visible focus, and screen reader announcements all passed.".split(
" ",
);
type ResponseState = "idle" | "streaming" | "complete" | "interrupted";
export function Example() {
const [responseState, setResponseState] = useState<ResponseState>("idle");
const [visibleWords, setVisibleWords] = useState(0);
useEffect(() => {
if (responseState !== "streaming") return;
if (visibleWords >= responseWords.length) {
setResponseState("complete");
return;
}
const timer = setTimeout(() => setVisibleWords((current) => current + 1), 90);
return () => clearTimeout(timer);
}, [responseState, visibleWords]);
const startResponse = () => {
setVisibleWords(0);
setResponseState("streaming");
};
const streaming = responseState === "streaming";
const response = responseWords.slice(0, visibleWords).join(" ");
let announcement = "";
if (responseState === "complete") announcement = "Response complete.";
if (responseState === "interrupted") announcement = "Response stopped.";
return (
<section aria-labelledby="stream-title" className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 id="stream-title" className="font-semibold text-zinc-950 dark:text-white">
Review assistant
</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
The live log waits until the response is complete before announcing it.
</p>
</div>
{streaming && (
<span aria-hidden="true" className="text-xs text-teal-700 dark:text-teal-300">
Streaming…
</span>
)}
</div>
<Messages
aria-labelledby="stream-title"
busy={streaming}
className="flex min-h-48 flex-col gap-3 rounded-xl border border-zinc-950/10 bg-white p-4 dark:border-white/10 dark:bg-zinc-900"
>
<article className="max-w-[85%] self-end rounded-lg bg-teal-700 px-3 py-2 text-sm text-white">
Did the accessibility review pass?
</article>
{responseState !== "idle" && (
<article className="max-w-[90%] self-start rounded-lg bg-zinc-100 px-3 py-2 text-sm text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100">
<strong className="sr-only">Assistant:</strong>
<p>{response || "…"}</p>
</article>
)}
</Messages>
<div className="flex items-center gap-2">
{streaming && (
<Button
className="rounded-lg border border-zinc-950/15 px-3 py-2 text-sm font-medium text-zinc-800 outline-teal-600 focus-visible:outline-2 dark:border-white/15 dark:text-zinc-100 dark:outline-teal-400"
onClick={() => setResponseState("interrupted")}
>
Stop generating
</Button>
)}
{!streaming && (
<Button
className="rounded-lg bg-teal-700 px-3 py-2 text-sm font-medium text-white outline-teal-600 focus-visible:outline-2 dark:bg-teal-400 dark:text-zinc-950 dark:outline-teal-300"
onClick={startResponse}
>
{responseState === "idle" ? "Generate response" : "Generate again"}
</Button>
)}
<Status className="text-sm text-zinc-600 dark:text-zinc-400">{announcement}</Status>
</div>
</section>
);
}Anatomy
Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.
A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.
Messages
Chronological message log with implicit polite live announcements. Owns a DOM element.
Step by step
- 1
Add the main part
Add Messages with an aria-label or aria-labelledby.
- 2
Add the supporting parts
Append each complete message at the end, with visible sender and time context.
- 3
Make the behavior clear
Keep the composer outside the history and set busy while a streamed message is being assembled.
Exampletsx <Messages aria-label="Conversation with Ada"> <p>Ada: Hello.</p> </Messages>;
Keyboard
Forms and accessibility
Messages does not create form values; keep the message composer beside it, not inside it.
Accessibility checklist
- Give the history an aria-label or aria-labelledby so its polite live region has a useful name.
- Append completed messages at the end; when prepending older history, temporarily set aria-live to off so old messages are not announced as new.
- Keep the message composer outside Messages, and do not move focus when a message arrives.
API reference
import { Button, Label, Messages, TextArea, TextField } from "@comp0/react";Messages
DOM elementChronological message log with implicit polite live announcements.
| Prop | Type | Description |
|---|---|---|
aria-label | string | Names the conversation history for assistive technology. |
aria-labelledby | string | Names the conversation history for assistive technology. |
busy | boolean | Defers live-region processing while a message is being assembled. |
aria-live | "off" | "polite" | "assertive" | Native override; use off temporarily while prepending older messages. |
as | ElementType | Renders another element in place of the default; Fragment merges the props into its single child. |
Style hooks
Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.
Messages
| Style hook | Meaning |
|---|---|
[data-busy] | A message is still being assembled before announcement. |