MC
Maya Chen
Hi, our password reset emails stopped arriving this morning. Sign-up emails still go out.
ComponentsMessages
A conversation: messages with their authors and times, attachments, statuses, a typing indicator and a composer.
import { ChatThread, ChatMessage, ChatBubble, ChatAttachment, ChatDateSeparator, ChatTypingIndicator, ChatComposer } from "@booleanpress/ui/chat"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.
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>
)
}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>
)
}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>
)
}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>
)
}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>
)
}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>
)
}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>
)
}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.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.| Key | Behaviour |
|---|---|
| Enter | In the composer, sends the message and empties the field; nothing happens while it is blank and holds no attachments. |
| ShiftEnter | In the composer, starts a new line. |
| EnterorSpace | On the send, attach, retry or New messages button, activates it. |
Renders a div and passes it every other prop.
Renders a article and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
authorrequired | 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. |
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. |
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | 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. |
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. |
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | 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. |
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).
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.
| 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 |