Chat Components

A compound family of three chat primitives — ChatList, ChatMessage, and TypingIndicator — for lessons that explain WebSocket protocols, debounced typing emissions, message grouping, or auto-scroll behavior. Drop in a messages[] array and the list mirrors it.

Preview
  • Mira joined the chat
  • Mira
    Can you explain what a typing indicator actually emits?
  • Helper
    Sure — most chat protocols debounce keystrokes and emit a single "typing" event after a quiet 300ms.
  • That's why your dots don't flicker on every keystroke.
  • Mira
    Ah, got it. Thanks!
Helper is typing...
Customize
Options

Installation

npx shadcn@latest add https://craftbits.dev/r/chat-components.json

Usage

import { ChatList } from "@craft-bits/core";
 
<ChatList
  messages={[
    { id: "1", role: "user", author: "Mira", content: "Hey!", timestamp: Date.now() },
    { id: "2", role: "assistant", author: "Helper", content: "Hi there.", timestamp: Date.now() },
  ]}
  typingUsers={["Helper"]}
/>

Compose your own list by reaching for the inner pieces directly:

import { ChatMessage, TypingIndicator } from "@craft-bits/core";
 
<ul role="list" className="flex flex-col gap-2">
  <ChatMessage message={{ id: "1", role: "user", author: "Mira", content: "Hi" }} />
  <ChatMessage message={{ id: "2", role: "assistant", author: "Helper", content: "Hello" }} />
</ul>
<TypingIndicator users={["Helper"]} />

Anatomy

  • ChatList. A <section role="log"> with a scrollable inner <ul>. Wraps each message in <ChatMessage>, animates entrance via AnimatePresence, and auto-scrolls the tail into view when new messages arrive.
  • ChatMessage. One <li> per message. role="user" aligns right with an accent-tinted bubble; role="assistant" aligns left; role="system" renders centered with a muted pill.
  • TypingIndicator. Three pulsing dots followed by a "X is typing..." label. Renders nothing when the users array is empty.

Understanding the component

  1. Role-driven layout. The author role (user / assistant / system) picks the alignment, the bubble tone, and the live-region behavior. No isOwn flag — the role is the ownership.
  2. Message grouping. Consecutive messages from the same role + author collapse their avatar and author line via groupConsecutive (default true). The bubble's top corner tightens so the cluster reads as one turn.
  3. Avatar hue. avatarHue lets you pin a hue; otherwise the author string is hashed into one of ten palette entries — stable across renders, no random colors.
  4. Auto-scroll. autoScroll (default true) calls scrollIntoView on a tail sentinel whenever the message count or typing list changes. Under prefers-reduced-motion, the scroll is "instant".
  5. Reduced motion. Every entrance animation, the typing dot pulse, and the auto-scroll all short-circuit to instant or skipped under prefers-reduced-motion.

Props

ChatList

PropTypeDefaultDescription
messagesChatMessageData[]requiredOrdered list of messages to render.
typingUsersstring[][]Authors currently composing — when non-empty, mounts a typing indicator.
hideTimestampsbooleanfalseHide the timestamp under every bubble.
groupConsecutivebooleantrueCollapse avatar + author line on follow-ups from the same role + author.
autoScrollbooleantrueScroll to the tail when the message count changes.
ariaLabelstring"Chat messages"Accessible label for the chat region.
classNamestringMerged onto the root via cn().

ChatMessage

PropTypeDefaultDescription
messageChatMessageDatarequiredThe message to render.
groupedbooleanfalseSuppress the avatar + author line for a follow-up turn.
hideTimestampbooleanfalseHide the time under the bubble.

ChatMessageData

FieldTypeDescription
idstringStable identifier — used as the React key.
role'user' | 'assistant' | 'system'Author role — drives alignment and tone.
contentReactNodeBody of the message.
authorReactNodeOptional display name.
timestampnumberMilliseconds since epoch. Rendered as a locale-formatted HH:MM.
avatarHuenumberHue (0–360) for the avatar circle. Defaults to a hash of the author string.

TypingIndicator

PropTypeDefaultDescription
usersstring[][]Users currently typing. Picks the singular/dual/plural label.
labelReactNodeOverride the rendered text. Bypasses the users label generator.

Accessibility

  • The list root is a <section role="log" aria-live="polite"> with a data-cb-edu="chat-list" hook. New messages are announced politely so screen readers do not interrupt mid-utterance.
  • Each message exposes data-cb-role="user" | "assistant" | "system" and data-cb-grouped so consumers can extend tone-specific styling without monkey-patching CSS.
  • Avatars use aria-hidden and a spacer placeholder keeps grouped messages indented under their leader.
  • TypingIndicator is a role="status" live region so the announcement reads as "Helper is typing..." once, not on every dot pulse.
  • Timestamps are wrapped in <time dateTime> with the ISO timestamp so assistive tech can read the absolute time.
  • Every entrance animation short-circuits under prefers-reduced-motion, and auto-scroll uses "instant" instead of "smooth".

Credits

  • Extracted from: terminal-dreams (src/components/frontend-design/sdp-chat/ui/ChatComponents.tsx). The original PersistentChat was a single 265-line component fused to a useChat context, a 6-flag step-feature gate (typing / delivery / reactions / readReceipts / encryption / sendMessage), a CSS-module styling layer, and the lesson's composer/input/keystroke-WS-debounce stats panel. craft-bits splits the rendering primitives out of the controller — no context, no step gates, no composer — leaving three slot-friendly pieces (ChatList, ChatMessage, TypingIndicator) that any chat lesson or app can compose.