ui · Primitive

Chat

@devalok/shilp-sutra/ui/chatView in Storybook
Live preview coming

Hand-curated previews ship in rolling waves. See it live in Storybook →

Reference

Seven primitives for building chat interfaces: MessageList, Message (compound), SystemMessage, MessageInput, DateSeparator, UnreadSeparator, TypingIndicator.


MessageList

Scrollable container with auto-scroll, load-more, empty state, and "N new" floating pill.

Props

autoScroll: boolean — auto-scroll to bottom on new content (default: true)
newMessageCount: number — count shown in floating pill (default: 0)
onScrollToBottom: () => void — called when user clicks the "N new" pill
onLoadMore: () => void — called when user scrolls near top
isLoadingMore: boolean — shows spinner at top (default: false)
emptySlot: ReactNode — content when there are no children
headerSlot: ReactNode — content above the scroll container (e.g. channel name)
scrollToBottomSlot: ReactNode — reserved slot for custom scroll-to-bottom button
children: ReactNode (REQUIRED)

Example

<MessageList autoScroll onLoadMore={loadMore} isLoadingMore={loading} newMessageCount={3} onScrollToBottom={markRead}>
  {messages.map(m => <Message key={m.id}>...</Message>)}
</MessageList>

Message (Compound)

Compound component: Message, Message.Avatar, Message.Content, Message.Author, Message.Body, Message.EditableBody, Message.Reactions, Message.Actions, Message.Action.

Message (root) Props

variant: "flat" | "bubble" (default: "flat")
placement: "start" | "end" (default: "start")
highlight: "mention" | "internal"
grouped: boolean — hides avatar/author for consecutive messages (default: false)
deleted: boolean — renders deleted placeholder (default: false)
deletedText: string (default: "This message was deleted")

Message.Avatar Props

src: string | null — avatar image URL
fallback: string — initials text
icon: ReactNode — custom icon instead of avatar
size: "sm" | "md" (default: "md")
children: ReactNode — fully custom avatar slot

Message.Author Props

name: string (REQUIRED)
badge: ReactNode
timestamp: Date
formattedTimestamp: string — pre-formatted timestamp (overrides timestamp)
timestampFormat: (date: Date) => string — custom formatter

Message.Body Props

children: ReactNode (REQUIRED)

Message.EditableBody Props

content: string (REQUIRED)
onSave: (newContent: string) => void (REQUIRED)
onCancel: () => void
canEdit: boolean (default: false)
renderContent: (content: string) => ReactNode — custom render for display mode

Message.Reactions Props

reactions: { emoji: string; count: number; reacted: boolean }[] (REQUIRED)
onReact: (emoji: string) => void (REQUIRED)

Message.Content Props

children: ReactNode (REQUIRED)
className: string

Message.Actions Props

children: ReactNode (REQUIRED)
delay: number — hover reveal delay in ms (default: 100)

Message.Action Props

icon: IconProps["icon"] (REQUIRED) — pass the Tabler component reference, e.g. `IconReply` (not `<IconReply />`)
label: string (REQUIRED)
onClick: () => void (REQUIRED)
variant: "default" | "danger" (default: "default")

Example

<Message variant="flat" highlight="mention">
  <Message.Avatar src={user.photo} fallback="JD" />
  <Message.Content>
    <Message.Author name="Jane Doe" timestamp={new Date()} badge={<Badge>Admin</Badge>} />
    <Message.Body>Hello, world!</Message.Body>
    <Message.Reactions reactions={reactions} onReact={handleReact} />
    <Message.Actions>
      <Message.Action icon={IconReply} label="Reply" onClick={handleReply} />
      <Message.Action icon={IconTrash} label="Delete" onClick={handleDelete} variant="danger" />
    </Message.Actions>
  </Message.Content>
</Message>

SystemMessage

Inline system event or alert message (e.g. "Alice joined the channel").

Props

icon: ReactNode — custom icon
timestamp: string — ISO timestamp string
variant: "event" | "alert" (default: "event")
children: ReactNode (REQUIRED)

Example

<SystemMessage>Alice joined the channel</SystemMessage>
<SystemMessage variant="alert" timestamp="2026-03-26T10:00:00Z">Connection lost</SystemMessage>

MessageInput

Auto-resizing textarea with send/stop buttons, streaming support, and slot-based extensibility.

Props

onSubmit: (text: string) => void (REQUIRED)
placeholder: string (default: "Type a message...")
disabled: boolean (default: false)
isStreaming: boolean — shows stop button instead of send (default: false)
onCancel: () => void — called when stop button is clicked
leadingSlot: ReactNode — content before the textarea (e.g. attachment button)
trailingSlot: ReactNode — content after the send button
disclaimer: string — centered text below the input (e.g. "AI can make mistakes")
sendIcon: ReactNode — custom send icon

Example

<MessageInput
  onSubmit={handleSend}
  isStreaming={streaming}
  onCancel={handleStop}
  disclaimer="AI can make mistakes"
/>

DateSeparator

Horizontal rule with a formatted date label.

Props

date: Date | string (REQUIRED)
format: (date: Date) => string — custom date formatter
className: string

Example

<DateSeparator date={new Date()} />
<DateSeparator date="2026-03-25" format={(d) => d.toLocaleDateString()} />

UnreadSeparator

Accent-colored horizontal rule marking the unread boundary.

Props

label: string (default: "NEW")
count: number — prepended to label (e.g. "5 NEW")
className: string

Example

<UnreadSeparator />
<UnreadSeparator count={5} />

TypingIndicator

Animated bouncing dots with a text description of who is typing.

Props

users: { name: string; image?: string }[] (REQUIRED)
className: string

Example

<TypingIndicator users={[{ name: 'Alice' }]} />
<TypingIndicator users={[{ name: 'Alice' }, { name: 'Bob' }]} />

Composability

  • Chat is a kit of 7 primitives, not a monolithic ChatWindow. Compose MessageList (scroll container) + Message/SystemMessage (row variants) + DateSeparator/UnreadSeparator (visual dividers) + MessageInput (composer) + TypingIndicator (presence).
  • Message is a compound componentMessage.Avatar, Message.Content, Message.Author, Message.Body, Message.Reactions, Message.Actions, Message.Action. Arrange them to match your design; most layouts only need Avatar + Content (with Author + Body inside).
  • Message.Actions reveal on hover via group-hover/message — ONLY works when Actions is nested inside a Message root. If you want a custom action bar, do it differently.
  • MessageList ARIA: role="log" + aria-live="polite" — screen readers announce new messages automatically. Don't manually add aria-live to children.
  • grouped mode: For consecutive messages from the same user, set grouped={true} to hide the avatar and author (typical chat UI pattern). Logic for "same user as previous" is consumer-side.
  • MessageInput is specifically for chat — auto-resizing textarea (up to 160px), Enter-to-send, Shift+Enter-for-newline, streaming support. For richer composition (formatting, attachments), use RichChatInput from composed (built on top of this + TipTap).
  • TypingIndicator accepts multiple users — handles pluralization of the "is/are typing" label automatically.

Gotchas

  • MessageList uses role="log" with aria-live="polite" — screen readers announce new messages
  • Message entrance animations use Framer Motion springs — AnimatePresence wraps children in MessageList
  • grouped hides avatar and author — use for consecutive messages from the same user
  • MessageInput sends on Enter (Shift+Enter for newline) — textarea auto-resizes up to 160px
  • TypingIndicator renders nothing when users is empty
  • Message.Actions toolbar is hidden by default (opacity-0) — it reveals on hover of the parent Message root via group-hover/message. Only works when Actions is inside a Message root.
  • Message.Content is the flex column wrapper for Author + Body — required for proper layout in flat variant
  • DateSeparator's default formatter shows "Today", "Yesterday", or "Mon DD" / "Mon DD, YYYY"

Changes

v0.29.0

  • Added Initial release — 7 chat primitives (MessageList, Message, SystemMessage, MessageInput, DateSeparator, UnreadSeparator, TypingIndicator)