Skip to the content

ComponentsMessages

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"

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.

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), 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 ChatBubbles (variant="plain" for text without a bubble, as an assistant's answers) and ChatAttachments: 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 ChatAttachments 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.

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.

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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
EnterIn the composer, sends the message and empties the field; nothing happens while it is blank and holds no attachments.
ShiftEnterIn the composer, starts a new line.
EnterorSpaceOn 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.

ChatMessage props
PropTypeDefaultDescription
authorrequiredReactNodeWho wrote it. Shown above the message, and read with it even when continued hides it.
avatarReactNodeAn Avatar beside the message.
continuedbooleanfalseA 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"startstart 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.
timeChatTimeWhen it was sent: formatted in the provider's locale and time zone.
timeFormatDateTimeFormatOptionsHow to write the time; the hour and minute by default.

ChatBubble

Renders a div and passes it every other prop.

ChatBubble props
PropTypeDefaultDescription
variant"default" | "plain"defaultdefault 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.

ChatAttachment props
PropTypeDefaultDescription
namerequiredstringThe file's name.
altstringThe picture's text alternative; the file's name by default. Pass "" for a picture that adds nothing to the text.
downloadstring | booleanDownloads the file instead of opening it, as the anchor's download attribute.
hrefstringWhere the file is: the name becomes a link, and the whole chip opens it.
sizenumberThe file's size in bytes, written in the provider's locale (84 kB).
srcstringA picture to show in place of the chip, for an image.
typestringThe file's MIME type, which picks its icon.

ChatDateSeparator

Renders a div and passes it every other prop.

ChatDateSeparator props
PropTypeDefaultDescription
dateChatTimeThe day the messages below were sent; written in the provider's locale and time zone.
formatDateTimeFormatOptionsHow to write the date; the weekday, day and month by default.

ChatTypingIndicator

Renders a div and passes it every other prop.

ChatTypingIndicator props
PropTypeDefaultDescription
namerequiredstringWho is typing: read as the provider's typing string, "{name} is typing".
avatarReactNodeAn Avatar beside the dots, as on that person's messages.

ChatComposer

Renders a form and passes it every other prop.

ChatComposer props
PropTypeDefaultDescription
acceptstringThe file types the picker offers, as the file input's accept.
defaultValuestringThe text to start with, uncontrolled.
disabledbooleanfalseStops writing, attaching and sending.
multiplebooleantrueLets 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.
placeholderstringThe 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.
valuestringThe 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--backgroundbackground
--borderborder, background
--cardbackground
--card-foregroundtext
--controlborder
--control-hoverborder
--destructiveoutline
--destructive-strongtext
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--mutedbackground
--muted-foregroundtext, background
--popoverbackground
--primarybackground
--primary-foregroundtext
--ringoutline, border
--secondarybackground
--secondary-foregroundtext
--subtlebackground