Skip to content

Pro pre-sale is open. Installation opens January 2027. See Pro

Components

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.

  • npm: 1 direct, 7 total
  • registry: 5 direct, 9 total

Preview

Today
Linh Tran
Can you send the brief before the call?
Sure, sending it now.
Seen
Linh Tran
Got it, thanks!
Linh Tran
I will read it before the call.

Props

  • label
  • typing
  • typingLabel
  • typingVariant
  • selectionMode
  • avatars
  • groupTyping
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

Today
Linh Tran
Can you send the brief before the call?
Sure, sending it now.
Seen
Linh Tran
Got it, thanks!
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.

Are we still on for 3?
Seen
Linh is typing
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.

Minh
The new icons are in.
Linh and An are typing
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.

Summarize the October report.
Assistant is writing
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.

Summarize the October report.
Assistant
Sales grew in two of three regions. The third held steady, and returns fell for the second month in a row.
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.

Linh Tran
Can you send the brief before the call?
Sure, sending it now.
Seen
Linh Tran
Got it, thanks!
1 selected
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

NameTypeValuesDefaultDescription
label requiredstring--Names the log for screen readers, such as Messages with Linh.
typingboolean-falseShows 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.
typingLabelstring--The words for who is typing, instead of the ones built from typingUsers, such as Linh is typing.
typingAvatarnode--An Avatar beside the typing indicator, so it lines up with that person's messages. Pass alt="".
typingVariantenumdefault | plaindefaultdefault 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.
stickToBottomboolean-trueFollows new messages, and replies that grow as they stream in, while the reader is at the end.
selectionModeboolean-falseShows 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.
selectedstring[]--Values of the picked messages, for controlled selection.
defaultSelectedstring[]--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 requirednode--MessageBubbles, oldest first, with a Separator label between days.

Anatomy

PartDescription
rootThe frame. Give it a height (h-96, flex-1); the viewport fills it.
viewportThe scrolling log.
contentThe column of messages, with padding so a focus ring never touches the edge.
typingThe typing row after the last message: the typing person's avatar, or an AvatarStack of two or more, the dots and who is typing.
typingBubbleA neutral bubble one line tall with three dots; with typingVariant plain, the dots alone.
typingTextWho is typing, in words, beside the dots, in secondary text, when typingUsers names them. Drawn only: the status region reads the same words.
dotOne 6px dot. The three pulse one after the other on the duration-loop cycle and hold still under reduced motion.
jumpThe 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

KeysAction
TabMoves focus to the log, then to the links and actions in the messages.
Arrow Up / Arrow DownWith the log focused, scrolls through the messages.
Page Up / Page Down, Home / EndWith the log focused, scrolls a page at a time, or to the first or the newest message.
EscapeIn 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-list

Run 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.