Chat

Chat shell, scrolling body, and message composer primitives.

Quick Preview

AO

Adaeze Okafor

Online

Would you like to schedule a viewing this week?

Yes, that would be great!

Is Saturday morning available?

Saturday 10 AM works perfectly. I'll send the address shortly.

Lekki Gardens Phase 2, Block C

Perfect, see you then. Thank you!

Looking forward to it! Let me know if you have any questions.

Will do

Composition

Chat is a column of a fixed header, a scrolling body, and a fixed footer. The header and footer sit outside the scroll region, so only ChatBody moves.

Chat (give it a bounded height)
├── ChatHeader
│   ├── ChatHeaderAction (back)
│   ├── ChatHeaderIdentity › ChatHeaderText › title / description
│   └── ChatHeaderActions › ChatHeaderAction…
├── ChatBody                     (the only scrolling region)
│   ├── ChatDateSection          (bounds one dated message group)
│   │   ├── ChatDateDivider      (sticky only inside its section)
│   │   └── MessageGroup › Message…
│   ├── ChatBodyBar (optional)   (sticky tabs / filter row)
└── ChatFooter
    └── ChatInput
        ├── ChatInputAttach
        ├── ChatInputField › ChatInputAction (optional)
        └── ChatInputSend

ChatBody keeps the newest message in view while the reader is at the bottom, hands control over the moment they scroll up, and holds the reading position when older messages load in above. Give Chat a height (h-125, h-full, h-dvh) — the body can only scroll inside a bounded parent.

Usage

<Chat aria-label="Conversation with Tunde Bakare" className="h-125">
  <ChatHeader>
    <ChatHeaderAction aria-label="Back"><ArrowLeft /></ChatHeaderAction>
    <ChatHeaderIdentity>
      <Avatar><AvatarFallback>AO</AvatarFallback></Avatar>
      <ChatHeaderText>
        <ChatHeaderTitle>Adaeze Okafor</ChatHeaderTitle>
        <ChatHeaderDescription>Online</ChatHeaderDescription>
      </ChatHeaderText>
    </ChatHeaderIdentity>
    <ChatHeaderActions>
      <ChatHeaderAction aria-label="Start video call"><Video /></ChatHeaderAction>
      <ChatHeaderAction aria-label="Start voice call"><Phone /></ChatHeaderAction>
    </ChatHeaderActions>
  </ChatHeader>

  <ChatBody>
    <ChatDateSection>
      <ChatDateDivider dateTime="2026-07-22">Today</ChatDateDivider>
      <MessageGroup>{/* …messages */}</MessageGroup>
    </ChatDateSection>
  </ChatBody>

  <ChatFooter>
    <ChatInput onSend={handleSend}>
      <ChatInputAttach onAttach={handleAttach} />
      <ChatInputField placeholder="Type a message" />
      <ChatInputSend />
    </ChatInput>
  </ChatFooter>
</Chat>

Examples

Full conversation

A bounded Chat with a header, a following ChatBody, and a composer. ChatInput is a real <form>, so Enter and the send button share one submit path; send a message and the body stays pinned to the newest bubble.

AO

Adaeze Okafor

Online

Would you like to schedule a viewing this week?

Yes, that would be great!

Is Saturday morning available?

Saturday 10 AM works perfectly. I'll send the address shortly.

Lekki Gardens Phase 2, Block C

Perfect, see you then. Thank you!

Looking forward to it! Let me know if you have any questions.

Will do

Composer

ChatInput is composable on its own. Drop ChatInputAction buttons inside ChatInputField for inline controls (emoji, mic), and the send button disables itself while the field is empty.

Custom attachment menu

ChatInputAttach defaults to File / Video / Image, but the menu is data-driven via items. Spread defaultChatInputAttachItems to add kinds without losing the defaults. An item with accept opens a filtered file picker (onAttach fires with the files and the item's kind); an item with onSelect runs a custom, non-file action instead — for "Share location", "Send contact", and the like.

<ChatInputAttach
  onAttach={handleAttach}
  items={[
    ...defaultChatInputAttachItems,
    { kind: "document", label: "Document", icon: <FileText />, accept: ".pdf,.doc" },
    { kind: "location", label: "Share location", icon: <MapPin />, onSelect: shareLocation },
  ]}
/>

Props

Prop/APITypeDefaultDescription
value on ChatInputstring-Controlled field value. Omit for uncontrolled.
defaultValue on ChatInputstring""Initial value when uncontrolled.
onValueChange on ChatInput(value: string) => void-Fires on every keystroke.
onSend on ChatInput(value: string) => unknown-Fires on submit with the trimmed text. Return false to keep the field.
clearOnSend on ChatInputbooleantrueClear an uncontrolled field after onSend accepts.
disabled on ChatInputbooleanfalseBlocks typing, sending, and attaching.
onAttach on ChatInputAttach(files: File[], kind: string) => void-Fires with the picked files and the requested kind.
items on ChatInputAttachChatInputAttachItem[]File / Video / ImageMenu entries. Spread defaultChatInputAttachItems to extend; use accept for a file picker or onSelect for a custom action.
align on ChatInputAction"inline-start" | "inline-end""inline-start"Which side of the field owns the action.
useChatBody(){ isPinned, scrollToBottom }-Read scroll state for a "jump to bottom" affordance. Call inside ChatBody.
classNamestring-Local layout or spacing overrides.
native/root propsReact component props-Passed through to the underlying element.

Accessibility

  • Give Chat an aria-label naming the conversation, and a bounded height so the body scrolls rather than the page.
  • ChatInput is a <form>; Enter submits (with an IME-composition guard) and the send button is the same submit path.
  • ChatInputAttach and each ChatInputAction are icon-only — provide an aria-label (the attach and send controls default one).
  • Preserve the header/body/footer structure so the fixed regions stay out of the scroll and assistive tech reads a stable landmark order.
  • Do not communicate state with colour alone; pair status with text or icons.

On this page