Skip to content

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.

On this page

Example

Loading example…
Messages.tsxtsx
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>
  );
}

Streaming response

Keep visible response text moving while aria-busy defers the live log until the complete or interrupted response is ready to announce.

Loading example…
messages.streaming.tsxtsx
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>
        ) : (
          <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.

  1. Messages

    Chronological message log with implicit polite live announcements. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Add Messages with an aria-label or aria-labelledby.

  2. 2

    Add the supporting parts

    Append each complete message at the end, with visible sender and time context.

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

Importtsx
import { Button, Label, Messages, TextArea, TextField } from "@comp0/react";

Messages

DOM element

Chronological message log with implicit polite live announcements.

PropTypeDescription
aria-labelstringNames the conversation history for assistive technology.
aria-labelledbystringNames the conversation history for assistive technology.
busybooleanDefers live-region processing while a message is being assembled.
aria-live"off" | "polite" | "assertive"Native override; use off temporarily while prepending older messages.

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 hookMeaning
[data-busy]A message is still being assembled before announcement.

Keep exploring