MessageList
The scrolling log of a conversation: MessageBubbles in order, oldest first, with a typing indicator after the last one. It opens at the newest message and follows new ones while the reader is at the end; once they scroll up it stays put and offers a button back down.
Preview
import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
import { Separator } from "@/components/ui/separator"
const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }
<MessageList label="Messages with Linh" className="h-80 w-full max-w-md rounded-container border border-border">
<Separator label="Today" />
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />}>Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m2" side="end" variant="accent" time="10:42" status="read" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />}>Sure, sending it now.</MessageBubble>
<MessageBubble value="m3" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />}>Got it, thanks!</MessageBubble>
<MessageBubble value="m4" grouped author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />}>I will read it before the call.</MessageBubble>
</MessageList>Usage
When to use
- Show the messages of a conversation in order, with new ones followed while the reader is at the end.
- Show that the other person or an assistant is writing a reply.
When not to use
- A feed of posts or events that is not a conversation. Use a list of Cards.
- Notifications that come and go. Use Toast.
- The steps an agent took. Use a list, or Stepper for a flow in order.
Examples
Conversation
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
import { Separator } from "@/components/ui/separator"
<MessageList label="Messages with Linh" className="h-80 w-full max-w-md rounded-container border border-border">
<Separator label="Today" />
<MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41">Can you send the brief before the call?</MessageBubble>
<MessageBubble side="end" variant="accent" time="10:42" status="read">Sure, sending it now.</MessageBubble>
<MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44">Got it, thanks!</MessageBubble>
</MessageList>Typing
The indicator sits after the last message; its words go to a status region.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList typing typingLabel="Linh is typing" typingAvatar={<Avatar alt="" color="blue">LT</Avatar>} label="Messages with Linh" className="h-64 w-full max-w-md rounded-container border border-border">
<MessageBubble side="end" variant="accent" time="10:42" status="read">Are we still on for 3?</MessageBubble>
</MessageList>Several people typing
typingUsers names everyone writing; the words say who, and the first avatar lines up with their messages.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
const typingPeople = [
{ name: "Linh", avatar: <Avatar alt="" color="blue">LT</Avatar> },
{ name: "An", avatar: <Avatar alt="" color="green">AP</Avatar> },
]
<MessageList typingUsers={typingPeople} label="Design team" className="h-64 w-full max-w-md rounded-container border border-border">
<MessageBubble author="Minh" avatar={<Avatar alt="" color="purple">M</Avatar>} time="09:12">The new icons are in.</MessageBubble>
</MessageList>Assistant thinking
typingVariant plain draws the dots without a bubble, where the plain reply will appear.
import { IconSparkles } from "@tabler/icons-react"
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList typing typingVariant="plain" typingLabel="Assistant is writing" typingAvatar={<Avatar alt="">
<IconSparkles size={16} />
</Avatar>} label="Chat with the assistant" className="h-64 w-full max-w-lg rounded-container border border-border">
<MessageBubble side="end">Summarize the October report.</MessageBubble>
</MessageList>Assistant
Replies from an assistant use plain bubbles; the reader's own messages stay in neutral bubbles at the end.
import { IconSparkles } from "@tabler/icons-react"
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
<MessageList label="Chat with the assistant" className="h-80 w-full max-w-lg rounded-container border border-border">
<MessageBubble side="end">Summarize the October report.</MessageBubble>
<MessageBubble variant="plain" author="Assistant" avatar={<Avatar alt="">
<IconSparkles size={16} />
</Avatar>}>Sales grew in two of three regions. The third held steady, and returns fell for the second month in a row.</MessageBubble>
</MessageList>Pick messages
In selection mode every message has a checkbox; a press on a row picks it, and Shift picks a range.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"
import { MessageList } from "@/components/ui/message-list"
const setSelectionMode = (selectionMode: boolean) => { /* Leave selection mode. */ }
const pickedMessages = ["m2"]
<MessageList selectionMode defaultSelected={pickedMessages} onSelectionModeChange={setSelectionMode} label="Messages with Linh" className="h-80 w-full max-w-md rounded-container border border-border">
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m2" side="end" variant="accent" time="10:42" status="read">Sure, sending it now.</MessageBubble>
<MessageBubble value="m3" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:44">Got it, thanks!</MessageBubble>
</MessageList>Do and don't
Do
- Give the list a fixed or flexible height, such as h-96 or flex-1 in a column, so it scrolls instead of growing the page.
- Turn typing on only while someone is writing: pass typingUsers from your realtime events (the other side's Composer onTypingChange), and clear it when their message arrives.
- Split days with a Separator label, such as Today.
- For selection, give every MessageBubble a value, and show the actions for the picked messages (Forward, Delete) in a bar beside the list.
Don't
- Do not scroll the reader down while they read older messages; leave stickToBottom on and let the jump button bring them back.
- Do not use a Spinner for typing; the typing indicator is part of the list.
- Do not put the Composer inside the list; place it below.
Props
| Name | Type | Values | Default | Description |
|---|---|---|---|---|
| label required | string | - | - | Names the log for screen readers, such as Messages with Linh. |
| typing | boolean | - | false | Shows the typing indicator after the last message and says who is typing once. typingUsers turns it on too. |
| typingUsers | { name: string; avatar?: React.ReactNode }[] | - | - | The people typing right now. Any entry turns typing on and names them beside the dots (EN `Linh and An are typing`). One person's avatar lines up with their messages; two or more overlap in an AvatarStack, three at most and +N. |
| typingLabel | string | - | - | The words for who is typing, instead of the ones built from typingUsers, such as Linh is typing. |
| typingAvatar | node | - | - | An Avatar beside the typing indicator, so it lines up with that person's messages. Pass alt="". |
| typingVariant | enum | default | plain | default | default draws the dots in a neutral bubble, like a message from a person; plain draws them without one, for an assistant that writes plain replies. |
| stickToBottom | boolean | - | true | Follows new messages, and replies that grow as they stream in, while the reader is at the end. |
| selectionMode | boolean | - | false | Shows a checkbox on every MessageBubble with a value. A press on a message picks it; Shift with a click picks every message between it and the last one picked. |
| onSelectionModeChange | (selectionMode: boolean) => void | - | - | Called with false when Escape is pressed in selection mode, so you can leave it. |
| selected | string[] | - | - | Values of the picked messages, for controlled selection. |
| defaultSelected | string[] | - | - | Values picked at first render, for uncontrolled selection. |
| onSelectedChange | (selected: string[]) => void | - | - | Called with the picked values, in the order of the messages. |
| labels | { jumpToLatest?: string; selected?: (count: number) => string; typing?: (names: string[]) => string } | - | - | The name of the jump button and the words announced for the selection and for who is typing. Defaults: Jump to latest, 2 selected, Typing, Linh is typing, Linh and An are typing, Linh, An and 2 others are typing. Vietnamese: Tới tin mới nhất, Đã chọn 2, Đang soạn tin, Linh đang soạn tin, Linh và An đang soạn tin, Linh, An và 2 người khác đang soạn tin. |
| children required | node | - | - | MessageBubbles, oldest first, with a Separator label between days. |
Anatomy
| Part | Description |
|---|---|
| root | The frame. Give it a height (h-96, flex-1); the viewport fills it. |
| viewport | The scrolling log. |
| content | The column of messages, with padding so a focus ring never touches the edge. |
| typing | The typing row after the last message: the typing person's avatar, or an AvatarStack of two or more, the dots and who is typing. |
| typingBubble | A neutral bubble one line tall with three dots; with typingVariant plain, the dots alone. |
| typingText | Who is typing, in words, beside the dots, in secondary text, when typingUsers names them. Drawn only: the status region reads the same words. |
| dot | One 6px dot. The three pulse one after the other on the duration-loop cycle and hold still under reduced motion. |
| jump | The secondary icon Button at the bottom that scrolls back to the newest message, shown while the reader is away from the end. |
Accessibility
Role: log
| Keys | Action |
|---|---|
| Tab | Moves focus to the log, then to the links and actions in the messages. |
| Arrow Up / Arrow Down | With the log focused, scrolls through the messages. |
| Page Up / Page Down, Home / End | With the log focused, scrolls a page at a time, or to the first or the newest message. |
| Escape | In selection mode, calls onSelectionModeChange(false). |
- The scrolling region is a log named by label (EN `Messages with Linh`), so a screen reader reads new messages as they arrive, politely.
- The log takes focus, so the keyboard can scroll it, and shows the focus ring.
- The typing indicator is drawn only. Its words (EN `Linh is typing`, `Linh and An are typing`) go to a separate status region outside the log, so they are read once when typing starts or the people change, and never as a message.
- The jump button is named by labels.jumpToLatest (EN `Jump to latest`, VI `Tới tin mới nhất`). While the reader is at the end it is hidden and out of the tab order.
- Following new messages never moves focus. Jumping moves focus back to the log, since the button hides.
- In selection mode a second status region announces the count, EN `2 selected`, VI `Đã chọn 2`, as it changes.
Install
npx werewolf-ui add message-listRun npx werewolf-ui init once in your app before adding components. Init sets up tokens, their CSS import and agent rules. Add installs this component and its dependencies while preserving your theme. See Installation for requirements and the alternative registry setup.