# ChatMessageList

**When to use:** The scrolling conversation of a chat: a messenger, a support widget, an AI assistant. It opens at the newest message, follows new ones while the user is at the bottom, and loads older ones at the top without moving the view. Fill it with [ChatMessage](ChatMessage.md)s and put a [ChatComposer](ChatComposer.md) under it. For a long list that is not a conversation, use [VirtualList](VirtualList.md).

**Keywords:** messenger, whatsapp, support chat, chatbot, assistant, LLM, history, thread, stick to bottom, autoscroll, infinite scroll, load older, jump to latest, unread

**Import:** `import { ChatMessageList } from '@ahrowe/ui'`  
**Types:** `import type { ChatMessageListProps, ChatMessageListHandle, ChatMessageListLabels } from '@ahrowe/ui'`

## A whole chat

```tsx
import { useRef, useState } from 'react';
import { Avatar, ChatComposer, ChatMessage, ChatMessageList, ChatMessageStatus, Divider } from '@ahrowe/ui';
import type { ChatMessageListHandle } from '@ahrowe/ui';

function Chat({ me, initial }: ChatProps) {
  const [messages, setMessages] = useState<Message[]>(initial);
  const [hasOlder, setHasOlder] = useState(true);
  const listRef = useRef<ChatMessageListHandle>(null);

  const loadOlder = async () => {
    const page = await api.messagesBefore(messages[0].id);
    setMessages((current) => [...page.messages, ...current]);
    setHasOlder(page.hasMore);
  };

  const send = (text: string) => {
    setMessages((current) => [...current, { id: crypto.randomUUID(), authorId: me.id, text, status: ChatMessageStatus.Sending }]);
    // Sending from further up the history takes the user down to their message.
    listRef.current?.scrollToBottom();
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100dvh' }}>
      <ChatMessageList ref={listRef} style={{ flex: 1 }} onLoadMore={hasOlder ? loadOlder : undefined}>
        <Divider text='Today' />
        {messages.map((message, index) => (
          <ChatMessage
            key={message.id}
            isOwn={message.authorId === me.id}
            isContinued={messages[index - 1]?.authorId === message.authorId}
            avatar={message.authorId === me.id ? undefined : <Avatar name={message.authorName} />}
            author={message.authorId === me.id ? undefined : message.authorName}
            status={message.status}
          >
            {message.text}
          </ChatMessage>
        ))}
      </ChatMessageList>
      <ChatComposer onSend={send} />
    </div>
  );
}
```

An AI chat is the same with a `Plain` answer that grows as it streams, and the composer's stop
button while it does:

```tsx
<ChatMessageList ref={listRef} style={{ flex: 1 }}>
  {turns.map((turn) =>
    turn.role === 'user' ? (
      <ChatMessage key={turn.id} isOwn>{turn.text}</ChatMessage>
    ) : (
      <ChatMessage key={turn.id} styleType={ChatMessageStyleType.Plain} actions={<CopyButton value={turn.text} />}>
        <Markdown>{turn.text}</Markdown>
      </ChatMessage>
    ),
  )}
</ChatMessageList>
<ChatComposer onSend={ask} onStop={isStreaming ? stop : undefined} />
```

## Scrolling

- **Following.** While the user is within 32px of the bottom, anything that makes the content
  taller keeps the newest message in view: a new message, an answer streaming in, an image
  loading, the list getting shorter because the composer grew or a phone keyboard opened.
- **Reading back.** As soon as the user scrolls up, however little, nothing moves under them: new
  messages arrive below, and a round button appears in the corner that jumps back down and starts
  following again. It turns `Primary` once a new message has arrived below. Scrolling back to the
  bottom by hand does the same.
- **After sending**, call `scrollToBottom()` on the handle, or a user who sent from further up
  never sees their own message arrive.
- **Older messages.** With `onLoadMore`, nearing the top calls it and shows a loader there.
  Prepend what it loads; the message that was at the top stays exactly where it was, at the very
  top too, where the browser's own scroll anchoring does nothing and Safari has none at all. It is
  called again while the loaded page still leaves the top near, but not after one that added
  nothing. If it rejects, it is called again after three seconds if the top is still near, or
  whenever the user next scrolls there. Pass `undefined` once there is nothing older.

**Sizing.** The list scrolls itself and fills the height it is given: `flex: 1` in a column, as
above, or a `height` on the list itself, which is how a widget in a `Card` keeps its size:
`<ChatMessageList style={{ height: '20rem' }}>`, with the `ChatComposer` after it. A conversation shorter than the list sits at the bottom, by the composer. To
start at the top instead, as an AI chat often does: `styles={{ content: { marginTop: 0 } }}`.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | Messages, oldest first, and anything between them: a `Divider` with a date, a typing indicator |
| `onLoadMore` | `() => Promise<void>` | Loads older messages as the top comes near, see above |
| `ref` | `Ref<ChatMessageListHandle>` | `scrollToBottom()`: back to the newest message, smoothly unless the user prefers reduced motion, and following again |

**Accessibility:** the scrolling element is a `role="log"`, which screen readers announce
politely as messages are added, named "Messages" (`labels.messages`). It is a tab stop of its own,
so the history can be scrolled from the keyboard, and `aria-busy` while older messages load.

**Global defaults:** adopts `ConfigProvider`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `viewport` (the scrolling log) `content` (the column of messages inside it) `scrollButton`

**Labels:** `messages` `scrollToBottom`: the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `ChatMessageListLabels`.
