Pho Design System

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…

Choose a conversation
It opens here.

Props

Chat.Root

Chat.Conversation

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

Best practices