# Chat

A conversation: messages with their authors and times, attachments, statuses, a typing indicator and a composer.

- **Import:** `import { ChatThread, ChatMessage, ChatBubble, ChatAttachment, ChatDateSeparator, ChatTypingIndicator, ChatComposer } from "@booleanpress/ui/chat"`
- **ARIA log role:** <https://www.w3.org/TR/wai-aria-1.2/#log>
- **Page:** <https://ui.booleanpress.com/components/chat> · @booleanpress/ui 0.2.0

## Usage

A thread is a scrolling log of messages; the composer below it writes new ones. The parts are flat, so a support ticket, a live chat and an assistant all use the same pieces.

```tsx
import { ChatBubble, ChatComposer, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

export function TicketThread({ messages, onSend }) {
  return (
    <>
      <ChatThread aria-label="Ticket #4821" className="h-96">
        {messages.map((m) => (
          <ChatMessage
            key={m.id}
            side={m.mine ? "end" : "start"}
            author={m.author}
            time={m.sentAt}
          >
            <ChatBubble>{m.text}</ChatBubble>
          </ChatMessage>
        ))}
      </ChatThread>
      <ChatComposer placeholder="Write a reply…" onSend={onSend} />
    </>
  )
}
```

`ChatThread` scrolls on its own: give it a height. It opens at the newest message and stays there while the reader is at the bottom: as messages arrive, as pictures load and as the thread's own height changes; once they scroll up it keeps their place and shows a **New messages** button that jumps down. A message the reader sends (`side="end"`) always brings the thread to the bottom.

`ChatMessage` takes the `author`, an `avatar` (an [avatar](/components/avatar)), the `time` (a `Date`, an ISO string or a timestamp, written in the provider's `locale` and `timeZone`) and, for the reader's own messages, a `status`: `sending`, `sent` or `failed`, which shows a retry button when you pass `onRetry`. `side="start"` is everyone else, `side="end"` the reader. `continued` hides the avatar, name and time of a follow-up from the same author; screen readers still hear them. Inside a message go one or more `ChatBubble`s (`variant="plain"` for text without a bubble, as an assistant's answers) and `ChatAttachment`s: a file chip with its type icon and size, a link when you pass `href`, or a picture when you pass `src` (its `alt` is the file's name unless you write one; `alt=""` hides a picture that adds nothing).

`ChatDateSeparator` writes a day between messages, or your own words as children. `ChatTypingIndicator` shows three dots and is read as "{name} is typing". `ChatComposer` is a field that grows with its text: Enter sends, Shift+Enter starts a new line, and the send button stays disabled while the field is blank and holds no attachments. `onSend` receives the trimmed text (`""` when attachments are sent alone) and the field empties; pass `value` and `onValueChange` to control it. `onAttach` adds a button that opens the file picker and hands you the files; show the chosen files as `ChatAttachment`s in the composer's children until they are sent.

## Examples

### Basic

A support ticket: the customer on the start side, the agent on the end side, a follow-up with `continued`.

```tsx
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { ChatBubble, ChatDateSeparator, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

const maya = (
  <Avatar>
    <AvatarFallback>MC</AvatarFallback>
  </Avatar>
)
const sam = (
  <Avatar>
    <AvatarFallback>SO</AvatarFallback>
  </Avatar>
)

export default function ChatBasic() {
  return (
    <ChatThread aria-label="Ticket #4821: reset emails not arriving" className="h-96 w-full max-w-xl rounded-lg border bg-card">
      <ChatDateSeparator date={new Date(2026, 9, 5)} />
      <ChatMessage author="Maya Chen" avatar={maya} time={new Date(2026, 9, 5, 9, 12)}>
        <ChatBubble>Hi, our password reset emails stopped arriving this morning. Sign-up emails still go out.</ChatBubble>
      </ChatMessage>
      <ChatMessage side="end" author="Sam Ortiz" avatar={sam} time={new Date(2026, 9, 5, 9, 18)}>
        <ChatBubble>
          Thanks, Maya. I can see 14 bounces from the reset template since 08:40. Did its sender address change?
        </ChatBubble>
      </ChatMessage>
      <ChatMessage author="Maya Chen" avatar={maya} time={new Date(2026, 9, 5, 9, 21)}>
        <ChatBubble>Yes, we moved it to accounts@northwind.test yesterday.</ChatBubble>
      </ChatMessage>
      <ChatMessage author="Maya Chen" avatar={maya} time={new Date(2026, 9, 5, 9, 21)} continued>
        <ChatBubble>Is that domain not verified yet?</ChatBubble>
      </ChatMessage>
      <ChatMessage side="end" author="Sam Ortiz" avatar={sam} time={new Date(2026, 9, 5, 9, 26)} status="sent">
        <ChatBubble>That is it: the new domain has no DKIM record. I have sent you the two records to add.</ChatBubble>
      </ChatMessage>
    </ChatThread>
  )
}
```

### With attachments

A delivery log as a file chip with its size, a screenshot as a picture, and a file sent back.

```tsx
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { ChatAttachment, ChatBubble, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

// A stand-in for a screenshot the customer sent: a mail client's bounce notice, drawn inline so the example needs no file.
const SCREENSHOT =
  "data:image/svg+xml," +
  encodeURIComponent(
    '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="#f8fafc"/><rect x="16" y="16" width="288" height="28" rx="4" fill="#e2e8f0"/><rect x="16" y="60" width="200" height="10" rx="5" fill="#fca5a5"/><rect x="16" y="80" width="260" height="10" rx="5" fill="#cbd5e1"/><rect x="16" y="100" width="230" height="10" rx="5" fill="#cbd5e1"/><rect x="16" y="140" width="96" height="24" rx="4" fill="#334155"/></svg>'
  )

export default function ChatAttachments() {
  return (
    <ChatThread aria-label="Ticket #4830: bounced invoices" className="h-96 w-full max-w-xl rounded-lg border bg-card">
      <ChatMessage
        author="Priya Nair"
        time={new Date(2026, 9, 5, 14, 2)}
        avatar={
          <Avatar>
            <AvatarFallback>PN</AvatarFallback>
          </Avatar>
        }
      >
        <ChatBubble>Here is the delivery log and what our customers see when an invoice bounces.</ChatBubble>
        <ChatAttachment name="delivery-log-october.csv" type="text/csv" size={48_200} href="#delivery-log" download />
        <ChatAttachment src={SCREENSHOT} alt="A bounce notice in a mail client, with the error line highlighted" name="bounce.png" />
      </ChatMessage>
      <ChatMessage
        side="end"
        author="Sam Ortiz"
        time={new Date(2026, 9, 5, 14, 9)}
        status="sent"
        avatar={
          <Avatar>
            <AvatarFallback>SO</AvatarFallback>
          </Avatar>
        }
      >
        <ChatAttachment name="dkim-records.txt" type="text/plain" size={1_240} href="#dkim-records" download />
        <ChatBubble>Add these two records at your DNS host; the bounces stop once they are live.</ChatBubble>
      </ChatMessage>
    </ChatThread>
  )
}
```

### Typing and statuses

Sent, failed with a retry button, and sending messages, and the other person typing. Retry sends the message again.

```tsx
import * as React from "react"
import { ChatBubble, ChatMessage, ChatThread, ChatTypingIndicator, type ChatMessageStatus } from "@booleanpress/ui/chat"

export default function ChatStatuses() {
  const [retried, setRetried] = React.useState<ChatMessageStatus>("failed")

  const retry = () => {
    setRetried("sending")
    // A fake request: the message goes through after a fixed delay.
    window.setTimeout(() => setRetried("sent"), 1200)
  }

  return (
    <ChatThread aria-label="Ticket #4835: API key rotation" className="h-96 w-full max-w-xl rounded-lg border bg-card">
      <ChatMessage side="end" author="You" time={new Date(2026, 9, 5, 11, 2)} status="sent">
        <ChatBubble>Your old API key stops working at midnight UTC.</ChatBubble>
      </ChatMessage>
      <ChatMessage side="end" author="You" time={new Date(2026, 9, 5, 11, 3)} status={retried} onRetry={retry}>
        <ChatBubble>The new key is in Settings → API keys, under “Production”.</ChatBubble>
      </ChatMessage>
      <ChatMessage side="end" author="You" time={new Date(2026, 9, 5, 11, 4)} status="sending">
        <ChatBubble>Let me know once your mailer uses it.</ChatBubble>
      </ChatMessage>
      <ChatTypingIndicator name="Leo Martin" />
    </ChatThread>
  )
}
```

### Date separators

`ChatDateSeparator` writes the day between messages, in the provider's locale and time zone.

```tsx
import { ChatBubble, ChatDateSeparator, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

export default function ChatDateSeparators() {
  return (
    <ChatThread aria-label="Ticket #4790: monthly report" className="h-96 w-full max-w-xl rounded-lg border bg-card">
      <ChatDateSeparator date={new Date(2026, 9, 1)} />
      <ChatMessage author="Ana Silva" time={new Date(2026, 9, 1, 16, 45)}>
        <ChatBubble>Could the monthly delivery report include the open rate per template?</ChatBubble>
      </ChatMessage>
      <ChatMessage side="end" author="You" time={new Date(2026, 9, 1, 17, 5)}>
        <ChatBubble>Good idea. I have passed it to the product team.</ChatBubble>
      </ChatMessage>
      <ChatDateSeparator date={new Date(2026, 9, 5)} />
      <ChatMessage side="end" author="You" time={new Date(2026, 9, 5, 9, 30)}>
        <ChatBubble>It is in this morning's release: Reports → Templates → Open rate.</ChatBubble>
      </ChatMessage>
      <ChatMessage author="Ana Silva" time={new Date(2026, 9, 5, 9, 52)}>
        <ChatBubble>Found it. Thank you!</ChatBubble>
      </ChatMessage>
    </ChatThread>
  )
}
```

### Composer

Enter sends: the message joins the thread as sending, then sent, and the thread scrolls to it.

```tsx
import * as React from "react"
import { ChatBubble, ChatComposer, ChatMessage, ChatThread, type ChatMessageStatus } from "@booleanpress/ui/chat"

type Message = { id: number; side: "start" | "end"; text: string; status?: ChatMessageStatus }

export default function ChatComposerExample() {
  const [messages, setMessages] = React.useState<Message[]>([
    { id: 1, side: "start", text: "Hello! Can I add a second sending domain on the Starter plan?" },
    { id: 2, side: "end", text: "Yes, Starter includes two domains.", status: "sent" },
  ])

  const send = (text: string) => {
    const id = messages.length + 1
    setMessages((current) => [...current, { id, side: "end", text, status: "sending" }])
    // A fake request: the message is marked sent after a fixed delay.
    window.setTimeout(() => {
      setMessages((current) => current.map((message) => (message.id === id ? { ...message, status: "sent" } : message)))
    }, 800)
  }

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <ChatThread aria-label="Chat with Omar Haddad" className="h-72 rounded-lg border bg-card">
        {messages.map((message) => (
          <ChatMessage
            key={message.id}
            side={message.side}
            author={message.side === "end" ? "You" : "Omar Haddad"}
            // Fixed times: one minute apart from 10:00.
            time={new Date(2026, 9, 5, 10, message.id)}
            status={message.status}
          >
            <ChatBubble>{message.text}</ChatBubble>
          </ChatMessage>
        ))}
      </ChatThread>
      <ChatComposer placeholder="Write a reply… (Enter sends, Shift+Enter adds a line)" onSend={send} onAttach={() => {}} />
    </div>
  )
}
```

### Long thread

Scroll up and add an event: the thread keeps your place and offers a jump to the new message.

```tsx
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { ChatBubble, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

const EVENTS = ["Queued", "Sent", "Delivered", "Opened", "Clicked", "Deferred", "Bounced"]

// A delivery log read as a thread: one message per event, a minute apart.
const event = (index: number) => ({
  id: index,
  text: `Campaign “October newsletter”: ${EVENTS[index % EVENTS.length].toLowerCase()} for recipient ${1040 + index}.`,
})

export default function ChatLongThread() {
  const [messages, setMessages] = React.useState(() => Array.from({ length: 24 }, (_, index) => event(index)))

  return (
    <div className="flex w-full max-w-xl flex-col gap-3">
      <ChatThread aria-label="Delivery events" className="h-80 rounded-lg border bg-card">
        {messages.map((message) => (
          <ChatMessage key={message.id} author="Delivery log" time={new Date(2026, 9, 5, 8, message.id)}>
            <ChatBubble>{message.text}</ChatBubble>
          </ChatMessage>
        ))}
      </ChatThread>
      <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
        <span>Scroll up, then add an event: the thread stays put and offers a jump.</span>
        <Button variant="outline" size="sm" onClick={() => setMessages((current) => [...current, event(current.length)])}>
          Add an event
        </Button>
      </div>
    </div>
  )
}
```

### Assistant style

The assistant's answers as plain text, the questions in bubbles, under a header and above a composer.

```tsx
import * as React from "react"
import { SparklesIcon, UserIcon } from "lucide-react"
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { ChatBubble, ChatComposer, ChatMessage, ChatThread } from "@booleanpress/ui/chat"

const assistant = (
  <Avatar size="sm">
    <AvatarFallback className="bg-primary text-primary-foreground">
      <SparklesIcon />
    </AvatarFallback>
  </Avatar>
)
const you = (
  <Avatar size="sm">
    <AvatarFallback>
      <UserIcon />
    </AvatarFallback>
  </Avatar>
)

const ANSWER = "Open Settings → Domains, choose the domain and copy the two DKIM records into your DNS host. Checks run every 10 minutes."

export default function ChatAssistant() {
  const [questions, setQuestions] = React.useState(["How do I verify a sending domain?"])

  return (
    <div className="flex w-full max-w-xl flex-col overflow-hidden rounded-lg border bg-card">
      <div className="flex items-center gap-3 border-b px-4 py-3">
        <Avatar>
          <AvatarFallback className="bg-primary text-primary-foreground">
            <SparklesIcon />
          </AvatarFallback>
        </Avatar>
        <div className="text-sm">
          <div className="font-semibold text-foreground">Mailer assistant</div>
          <div className="text-muted-foreground">Ask about domains, templates and delivery logs</div>
        </div>
      </div>
      <ChatThread aria-label="Mailer assistant" className="h-72">
        {questions.map((question, index) => (
          <React.Fragment key={index}>
            <ChatMessage side="end" author="You" avatar={you} time={new Date(2026, 9, 5, 15, index * 2)}>
              <ChatBubble>{question}</ChatBubble>
            </ChatMessage>
            <ChatMessage author="Assistant" avatar={assistant} time={new Date(2026, 9, 5, 15, index * 2 + 1)}>
              <ChatBubble variant="plain">{ANSWER}</ChatBubble>
            </ChatMessage>
          </React.Fragment>
        ))}
      </ChatThread>
      <ChatComposer className="m-3 mt-0" placeholder="Ask a question" onSend={(text) => setQuestions((current) => [...current, text])} />
    </div>
  )
}
```

## Accessibility

**Semantics.** The thread is a `role="log"` region: assistive technology reads messages added to it, politely, without moving focus; the messages already there when it loads are not announced. Each message is an `article`; its author and its `time` (with a machine-readable `datetime`) come before its content, so they are read first. A status is text; the typing indicator is a hidden sentence beside dots that are hidden from screen readers. The composer is a `form` with a `textarea` and two native buttons.

**Labels.** The thread is named by the provider's `chatThread` string ("Conversation") unless you pass `aria-label`: name it after the ticket or the person. The field is named by `chatMessage` ("Message") unless you pass `aria-label`; the buttons by `sendMessage` and `attachFile`; the jump by `newMessages`; the statuses by `messageSending`, `messageSent` and `messageFailed`, and the retry button by `retry`. Give a picture attachment an `alt`; without one it is named by the file's name. Avatars are hidden from screen readers, as the author's name is read with each message.

**Focus.** The thread joins the tab order while it scrolls with nothing focusable inside, so the arrow keys can scroll it; otherwise focus moves through its links and buttons. The jump button moves focus to the thread as it goes. After a send, focus stays in the field. Retry leaves focus on the message's status, which then reads "Sending".

**Known limits.**

- Every message added to the thread is announced, including the reader's own; statuses that change are announced too. A very busy thread may be better with the newest messages only.
- The jump appears for new messages only; a reader who scrolls up without new messages scrolls back themselves.
- The composer's height follows its text up to 160 px, then it scrolls.
- Enter that confirms an input method's composition (Japanese, Chinese, Korean) does not send.
- The thread does not load older messages as you scroll up; add them to the start of the list yourself.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter | In the composer, sends the message and empties the field; nothing happens while it is blank and holds no attachments. |
| Shift + Enter | In the composer, starts a new line. |
| Enter or Space | On the send, attach, retry or New messages button, activates it. |

## API

### ChatThread

Renders a `div` and passes it every other prop.

### ChatMessage

Renders a `article` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `author` (required) | `ReactNode` |  | Who wrote it. Shown above the message, and read with it even when `continued` hides it. |
| `avatar` | `ReactNode` |  | An `Avatar` beside the message. |
| `continued` | `boolean` | `false` | A follow-up from the same author: the avatar, name and time are hidden, and still read by screen readers. |
| `onRetry` | `(() => void)` |  | Called by the retry button a failed message shows. Without it, a failed message shows no button. |
| `side` | `"start" \| "end"` | `start` | `start` for the other people in the thread, `end` for the reader's own messages: their side and bubble colour. |
| `status` | `"sending" \| "sent" \| "failed"` |  | Where a message the reader sent stands, shown under it. |
| `time` | `ChatTime` |  | When it was sent: formatted in the provider's locale and time zone. |
| `timeFormat` | `DateTimeFormatOptions` |  | How to write the time; the hour and minute by default. |

### ChatBubble

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "plain"` | `default` | `default` draws the bubble: the muted fill for others, the primary fill for the reader. `plain` is text alone. |

### ChatAttachment

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `string` |  | The file's name. |
| `alt` | `string` |  | The picture's text alternative; the file's `name` by default. Pass `""` for a picture that adds nothing to the text. |
| `download` | `string \| boolean` |  | Downloads the file instead of opening it, as the anchor's `download` attribute. |
| `href` | `string` |  | Where the file is: the name becomes a link, and the whole chip opens it. |
| `size` | `number` |  | The file's size in bytes, written in the provider's locale (`84 kB`). |
| `src` | `string` |  | A picture to show in place of the chip, for an image. |
| `type` | `string` |  | The file's MIME type, which picks its icon. |

### ChatDateSeparator

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `ChatTime` |  | The day the messages below were sent; written in the provider's locale and time zone. |
| `format` | `DateTimeFormatOptions` |  | How to write the date; the weekday, day and month by default. |

### ChatTypingIndicator

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` (required) | `string` |  | Who is typing: read as the provider's `typing` string, "{name} is typing". |
| `avatar` | `ReactNode` |  | An `Avatar` beside the dots, as on that person's messages. |

### ChatComposer

Renders a `form` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `accept` | `string` |  | The file types the picker offers, as the file input's `accept`. |
| `defaultValue` | `string` |  | The text to start with, uncontrolled. |
| `disabled` | `boolean` | `false` | Stops writing, attaching and sending. |
| `multiple` | `boolean` | `true` | Lets the picker choose more than one file. |
| `onAttach` | `((files: File[]) => void)` |  | Shows the attach button: it opens the system's file picker and passes the chosen files here. |
| `onSend` | `((text: string) => void)` |  | Called with the text, trimmed, when Enter or the send button sends it. The field then empties. With attachments in the composer's children, it can be called with `""`: the files are sent alone. |
| `onValueChange` | `((value: string) => void)` |  | Called as the text changes, and with `""` once it is sent. |
| `placeholder` | `string` |  | The hint shown while the field is empty. |
| `size` | `"default" \| "sm" \| "lg"` |  | The text size, padding and buttons: 12, 14 or 16 px text. Defaults to the provider's `controlSize`. |
| `value` | `string` |  | The text being written, controlled. |
| `variant` | `"default" \| "filled"` |  | `filled` draws the grey `--field-filled` fill. Defaults to the provider's `fieldVariant`. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="chat-thread"` (ChatThread), `data-slot="chat-message"` (ChatMessage), `data-slot="chat-bubble"` (ChatBubble), `data-slot="chat-attachment-image"` (ChatAttachment), `data-slot="chat-date-separator"` (ChatDateSeparator), `data-slot="chat-typing-indicator"` (ChatTypingIndicator), `data-slot="chat-composer"` (ChatComposer), and `data-side`, `data-status`, `data-continued`, `data-variant`, `data-size`, `data-disabled`.

**Provider strings:** `chatThread`, `newMessages`, `messageSending`, `messageSent`, `messageFailed`, `retry`, `typing`, `attachFile`, `chatMessage`, `sendMessage` (`BooleanUIProvider`'s `strings`).

## Theming

Bubbles from others are `--muted` with `--foreground` text; the reader's are `--primary` with `--primary-foreground`. Names are `--foreground`, times, statuses and the date separator `--muted-foreground`, a failed status `--destructive-strong`. File chips are `--card` with the `--border` edge and the type icon on `--secondary`. The composer has the field look: `--field` (`--field-filled` when filled), the `--control` edge and `--ring` while focused.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--background` | background |
| `--border` | border, background |
| `--card` | background |
| `--card-foreground` | text |
| `--control` | border |
| `--control-hover` | border |
| `--destructive` | outline |
| `--destructive-strong` | text |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--field-filled` | background |
| `--foreground` | text |
| `--muted` | background |
| `--muted-foreground` | text, background |
| `--popover` | background |
| `--primary` | background |
| `--primary-foreground` | text |
| `--ring` | outline, border |
| `--secondary` | background |
| `--secondary-foreground` | text |
| `--subtle` | background |
