Chat
An inbox in one card: the conversations down the left, the open thread on
the right with its messages scrolling up from the composer. It composes
the smaller parts — Message rows of
Bubbles in a ScrollArea, an Input for search,
Buttons to send and go back — the way Sidebar composes a rail: rows
that press, one active, links through the host’s LinkProvider.
+1 (315) 997-8332
From +1 (530) 784-9134
Import
import { Chat } from "@photon-ai/pho-ui/components/chat";
Anatomy
Root is the card — give it a height. List holds a Search row and the
scrolling Conversations, which holds a ConversationList of
Conversation rows, all on the page wash (bg-pho-page). Thread holds
a Header (Title, Description), the scrolling Messages, and a docked
Footer — usually a Composer — on the card (bg-pho-primary).
<Chat.Root className="h-[32rem]">
<Chat.List>
<Chat.Search value={query} onChange={(e) => setQuery(e.target.value)} />
<Chat.Conversations>
<Chat.ConversationList>
<Chat.Conversation
title="+1 (315) 997-8332"
time="2d ago"
preview="Photon websocket-e2e d3e734b8"
active
href="?space=sp_1"
/>
</Chat.ConversationList>
</Chat.Conversations>
</Chat.List>
<Chat.Thread>
<Chat.Header avatar={<Avatar.Root size="md">…</Avatar.Root>}>
<Chat.Title>+1 (315) 997-8332</Chat.Title>
<Chat.Description>From +1 (530) 784-9134</Chat.Description>
</Chat.Header>
<Chat.Messages hasEarlier={hasNextPage} onLoadEarlier={fetchNextPage}>
<Chat.Divider>September 12</Chat.Divider>
<Message.Root>…</Message.Root>
</Chat.Messages>
<Chat.Footer>
<Chat.Composer value={draft} onValueChange={setDraft} onSend={send} />
</Chat.Footer>
</Chat.Thread>
</Chat.Root>
Layouts
default seats both panes side by side. The divider between them drags
so either pane can grow; the list starts at 273px and will not go
below min-w-64 (256), the thread not below min-w-80 (320). compact
shows one at a time —
the list, then the thread with a way back in its header — for a narrow
column or a phone. Opening a conversation pushes the thread in from the
right; the back button pops the list in from the left. Which pane is up
is pane, controlled with onPaneChange or left to the card.
auto is compact only while the frame is narrower than 42rem. It is a
container query, so it follows the card, not the viewport: the same page
can be wide in a main column and compact in a side panel.
+1 (315) 997-8332
From +1 (530) 784-9134
Conversations
Conversation is one row: the default avatar (a person, or people for a
group), the title, the time trailing, and one truncated preview
line. It is a link with href (through LinkProvider — an inbox that
puts the open thread in the URL) or a button with onClick. active is
the open one.
<Chat.Conversation
title="+1 (725) 699-0248, +1 (540) 390-8722"
time="2d ago"
preview="+1 (725) 699-0248: ?"
group
href="?space=sp_3"
/>
Messages
Messages is the thread’s scroll pane on the card surface. It opens at the
bottom and stays pinned there as messages arrive — unless the reader has
scrolled up, in which case it keeps their place. With hasEarlier, a
button at the top asks onLoadEarlier for the page before; when it
arrives above, the pane holds what they were reading. Divider is a
centered caption for the day.
Composer
The message box: text that grows with the draft, Enter to send and
Shift+Enter for a new line, one round send button that wakes once there is
a non-blank character. Attachments are the page’s: put their thumbnails in
children, the button that adds them in prefix, a count in meta, and
flip canSend when they alone should send.
<Chat.Composer
value={draft}
onValueChange={setDraft}
onSend={send}
sending={isPending}
canSend={draft.trim() !== "" || images.length > 0}
placeholder="Message"
prefix={
<Button
variant="ghost"
size="sm"
shape="circle"
svgOnly
aria-label="Add images"
>
<IconPlus />
</Button>
}
meta={images.length ? `${images.length} images` : null}
>
{thumbnails}
</Chat.Composer>
Loading and empty
ConversationsLoading lays bones in the rows’ place; MessagesLoading in
the thread’s. Empty is StatusView at card size, centered in whichever
pane it sits in — no conversations, no thread chosen, nothing said yet.
+1 (315) 997-8332
Loading…
Props
Chat.Root
- layout —
default | compact | auto— defaultdefault - pane / defaultPane / onPaneChange —
list | thread— which pane is up in a compact layout - …plus any
<div>attribute; give it a height
Chat.Conversation
- title —
ReactNode— who - time —
ReactNode— trailing, tabular - preview —
ReactNode— one truncated line - avatar —
ReactNode— replaces the default - group —
boolean— the default avatar shows people - active —
boolean— the open one (aria-current) - href —
string— the row is a link throughLinkProvider - …plus any
<a>/<button>attribute
Chat.Header: avatar, actions, backLabel (default Back to conversations).
Chat.Messages: hasEarlier, loadingEarlier, onLoadEarlier, earlierLabel.
Chat.Composer: value, onValueChange, onSend, placeholder, sending, disabled, canSend, prefix, meta, sendLabel, children, textareaRef.
Chat.Search: Input props except size, icon, label, hint.
Chat.Empty: StatusView props (size defaults to sm).
Chat.ConversationsLoading: rows (default 5).
Accessibility
- The list is a
complementarylandmark named Conversations; the thread aregionnamed Conversation; the title is the page’sh2. - In a wide layout the divider is a focusable separator. Arrow keys move it; Home and End jump to the min and max.
- Each row is one button or one link; the open one carries
aria-current. - The composer is a form named Message composer; the box is Message; the send button is Send and stays disabled until there is something to send.
- Enter sends only outside an IME composition.
Best practices
- The page fetches, formats times, and decides who is who; the card shows what it is given.
- Put the open thread in the URL and render rows as links, so a thread can be shared.
- Key
Threadby the open conversation so its scroll position starts fresh. The composer’svalueis the host’s state: keep drafts in a map by conversation id so a half-written message survives switching away (and a failed send), and never leaks into another thread.