<!-- Generated by scripts/gen-docs.mts from the JSDoc in registry/. Do not edit; run `pnpm run docs`. -->

# MessageList

A conversation: `Message`s in a scrolling, labeled region that opens at the newest one. A short conversation sits at the region's bottom, by the composer under it, as in a chat app; put what starts it (who it is with, a way to older history) first among the children, and it sits right above the first message. While the reader is at the bottom it follows new messages and streaming text; once they scroll up, nothing moves their view, and a "Jump to latest" button appears. At the bottom it also follows what reaches past its end without changing any message's size, such as the action bar beside a reply's last line. Nor does something in a message that gets shorter for a moment and longer again, such as a picture's placeholder swapped for the picture, pull it off its end. Each new message from someone else is read to screen readers once, politely: a streamed reply when it finishes, and a first reply in a list that mounted empty. History is never announced: the messages it mounts with, several messages filling an empty list at once (history fetched after mount), older messages loaded above, and a message whose `key` changes in place (a temporary id swapped for the server's). Every message re-keyed at once is not held that way: the view moves a little, since windowing holds a place by a child's key. Older messages loaded above a scrolled-up reader leave the message they are reading where it was, in every frame, and so does a message above them that moves, such as a post a reply moves to the end. When the message they are on is the one that moves, the messages around it keep their place. A page of many older messages goes in 20 a frame, those nearest the reader first, so loading it never holds the page up for long (when the messages are the list's own children, each with a `key`). Switching to another conversation is opening one: key the list by the conversation, or give it the other conversation's messages with its `conversation` (or, without one, messages that share no `key` with those it held), even after it held none for a while. Either way it opens at the other's newest message, wherever the reader was, with nothing announced; focus that was on a message moves to the newest one in view. A scroll up from the end stops the list following new messages at once, before the browser reports it. The list follows by how far it is from the bottom, not which way it last scrolled, so your code going to a message (`scrollIntoView`) stops it too, even as the first messages arrive. Scrolling is never announced.

```tsx
import { MessageList } from "@/components/ouro/message/message-list";
```

## Example

```tsx
<MessageList label="Conversation with Ouro" className="h-[60dvh]">
  {messages.map((message) => (
    <Message key={message.id} role={message.role} name={message.author} streaming={message.streaming}>
      {message.text}
    </Message>
  ))}
</MessageList>
<MessageList
  label="Team chat"
  className="flex-1"
  onMessageKeyDown={(message, event) => {
    const reaction = quickReactions[Number(event.key) - 1];
    if (reaction) react(message.id, reaction);
  }}
>
  {posts.map((post) => (
    <article key={post.id} id={post.id} data-ouro-message-list-item="" data-role={post.mine ? "user" : "assistant"}>
      <span data-ouro-message-author="">{post.author}</span>
      <p data-ouro-message-content="">{post.text}</p>
    </article>
  ))}
</MessageList>
```

## Usage

While older history may load, set `hasOlder`, and `loadingOlder` while it loads: the list keeps room above the first message for "Loading older messages", so nothing moves as it shows. Fetch older pages ahead of the reader, newer before older, rather than waiting for them to reach the top, so they seldom see it.

Give it a height (`className="h-96"`, or `flex-1` in a column), since it scrolls inside it. Nothing in it overflows the boxes around it, so moving focus or scrolling a message into view scrolls the list, not the page around it.

A conversation of more than 60 children draws only the messages near its view (`windowing`), each measured as it draws, with space kept for the rest, so a long conversation opens and redraws in about the time a screenful does. The reader's place holds as the messages above them are measured and as older ones load above, in one frame. The newest message, the one with focus and any still streaming stay drawn, and the arrow keys, Home and End reach every message, drawing the one they reach. The browser's find in page sees only what is drawn: search your own messages and call `actionsRef.current.reveal(id)` to show a match, as a quote does. Each child sits in an element of its own that draws no box (`display: contents`, marked `data-ouro-message-list-slot`), so CSS that selects the list's direct children selects those elements, in a list of any length.

The messages spring as the reader scrolls, as in a phone's messages app: each trails the scroll a little and catches up, the farther from the pointer, finger or focused message the more, and a fling or a push past either end spreads them apart for a moment. The scroll position never moves for it, and at rest, once the reader's scroll has ended, no message has a transform. `springy={false}` turns it off, and it is off with reduced motion.

The messages are one Tab stop. Tab lands on the newest message in view (or the one focused last, while it shows), and the arrow keys, Home, End, Page Up and Page Down move between messages, with a focus ring; a message taller than the list scrolls through first. The right arrow (the left one, right to left) goes into the focused message's toolbar, such as its action bar, else its first control, and Tab goes through its controls. Tab never stops on the other messages' controls, so the next Tab after a message's own controls leaves the list. Nor does it stop on the focus guards an open menu puts around its button, so Tab from a message whose menu is open goes on through its controls rather than back to the message. A new message never takes focus, and when the focused message goes, focus goes to the one now in its place. Each message that is an article carries its place among them for screen readers (`aria-posinset`, `aria-setsize`). `onMessageKeyDown` adds keys of your own on the focused message, such as 1 to 6 for quick reactions.

On a Mac the list reads ⌘↑, ⌘↓, ⌥↑ and ⌥↓ on a focused message as Home, End, Page Up and Page Down, and calls `preventDefault()` on them. Listen for an app shortcut on those keys in the capture phase (`window.addEventListener("keydown", onKey, { capture: true })`), which runs before the list: call `preventDefault()` there and the list leaves the key alone. Or set `pageKeys={false}` to leave Page Up, Page Down, ⌥↑ and ⌥↓ to the app.

Your own message components work too: mark each one's root `data-ouro-message-list-item`, the reader's own with `data-role="user"` and one still streaming with `aria-busy="true"`, and put its author's name and its text in elements marked `data-ouro-message-author` and `data-ouro-message-content`, as `Message` does. The list keeps the reader's place among them and announces them as it does `Message`s, and moves focus among them. Give your component a focus ring (`focus-visible:outline-2`), as `Message` has. Leave `data-ouro="message"` to Ouro's `Message`: it names the element Ouro's Message, and X-ray outlines it as one.

## Use instead when

- Use a plain list instead for messages that do not arrive live, such as an inbox.

## Props

Type: `MessageListProps`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actionsRef` | `Ref<MessageListActions>` |  | Gets the list's actions: `reveal(id)` brings a message into view, drawing it first if it is not drawn, for a quote, a link to a message, or find in chat; `jumpToNewest()` goes to the end and follows new messages again. See `MessageListActions`. |
| `children` (required) | `ReactNode` |  | The `Message`s, oldest first, or your own message components marked `data-ouro-message-list-item` (see `MessageList`). |
| `className` | `string` |  | Layout only: the list's height (`h-96`, `flex-1`), margins. It scrolls inside that height, so give it one. Appearance classes warn in development. |
| `conversation` | `Key` |  | Which conversation the messages are, such as its id. Change it with the other conversation's messages, or before they come (as an app that keeps the last messages while it fetches does): the list opens that conversation at its newest message as they come, whatever keys they have, and moves nothing while the last one's still show. While it stays the same, nothing the messages do counts as a switch: keys swapped for the server's, the newest ones regenerated or removed. The first one named for messages the list already holds names them, and moves nothing. Messages that come before their conversation is named are taken for the last one's, and the name that follows them is taken for theirs, opening nothing; and a conversation whose messages carry exactly the last one's keys (keyed by place, as many of them) can't be told from it until they change, so key messages by their own ids. Without it, the list takes messages that share no `key` with the ones it held for another conversation, so it misses a switch between conversations whose messages share keys, and takes a short conversation whose every message gets a new key at once for another one. |
| `hasOlder` | `boolean` | `false` | Whether older messages may still load above, until the reader reaches the start of the conversation. While it is true the list keeps room above the first message, where "Loading older messages" shows, so at the top it covers no message or focus ring, and nothing moves as loading starts. Set it to `false` once there is nothing older: the room stays until it has left the view, or the list is at its end, and then goes without moving a message, not even for the one frame the browser draws as it goes. |
| `heightCache` | `Map<string, number>` |  | A map the list writes the heights of its children into, under a child's key and the list's width in CSS pixels, rounded to a multiple of `BUCKET`, and reads before it measures. Keep one in your app, by conversation, and hand it to each list that shows the conversation: a list that mounts again with it is as tall, from its first commit, as the list before it was, so it opens with no row moving. Leave it out and the list keeps its own for as long as it lives. |
| `initialAnchor` | `string` |  | The `id` of the message to open at, such as one found by search or a link to it, or the `key` of the child that holds it (the message's own id, where a row component of yours takes no `id`). The list opens with that message in the middle of its view, before it first draws, so the newest is never shown first, and it follows new messages only once the reader reaches the end. The list reads it when it first has messages, so it can come with them; a message it does not hold, or the newest, opens at the end, and a later change moves nothing. |
| `jumpLabel` | `string` | `"Jump to latest"` | The button that appears once the reader scrolls up, for other languages. |
| `label` (required) | `string` |  | What the conversation is, for screen readers: "Conversation with Ouro". Required, so the region has a name. |
| `loadingOlder` | `boolean` |  | Whether older messages are loading above: a small "Loading older messages" shows over the top of the list, and screen readers hear it once. It sits over the messages, not among them, so starting and ending it moves no message. At the very top it covers the first message unless `hasOlder` keeps room for it. Fetch older pages ahead of the reader, not when they reach the top: the next older page as soon as the one before it has arrived, while they are still screens away. Then this shows only when they get to the top first. |
| `loadingOlderLabel` | `string` | `"Loading older messages"` | What shows while older messages load, for other languages. |
| `maxWidth` | `number` |  | The widest the conversation gets, in CSS pixels, centred in a wider list (`960` is 60rem at the default font size), so in a wide window the user's own messages stay near the others' and the eye never crosses an empty field between them. The list itself still fills its place, so it scrolls from anywhere across it. Leave it out for messages as wide as the list. |
| `newMessageLabel` | `((author: string, text: string) => string)` | `` (author, text) => `${author}: ${text}` `` | What screen readers hear when a new message arrives, for other languages. It gets the author's name and the message's text. The user's own messages are not read back. |
| `onMessageKeyDown` | `((message: HTMLElement, event: KeyboardEvent<HTMLElement>) => void)` |  | A key pressed while a message itself has focus, not a control inside it, before the list's own keys: shortcuts on the focused message, such as 1 to 6 for quick reactions. `message` is the message's element (its `id` or `data-*` attributes say which one it is). Call `event.preventDefault()` to keep the list from acting on the key as well. |
| `pageKeys` | `boolean` | `true` | Whether Page Up and Page Down on a focused message move a page through the messages, as ⌥↑ and ⌥↓ do on a Mac. With `false` the list leaves those keys alone: it neither acts on them nor calls `preventDefault()`, so an app's own shortcut on them, such as ⌥↓ for the next conversation, gets them as pressed, and the browser scrolls the list. The other keys still move between messages. |
| `ref` | `Ref<HTMLDivElement>` |  | Reaches the rendered element. |
| `springy` | `boolean` | `true` | Whether the messages spring as the reader scrolls, as in a phone's messages app: each one trails the scroll a little, the farther from where the reader scrolls (the finger, the pointer, the focused message) the more, and catches up on a spring. A fling, or a push past either end, spreads the messages apart and they settle back. It draws with `transform` alone: the list's scroll position never moves for it, and at rest, once the reader's scroll has ended, no message has a transform. Only the messages in or near view move, so a long conversation costs what a short one does. It runs for the reader's own wheel, trackpad, touch and keys, never for scrolling the list or your code does (following new messages, setting `scrollTop`), and never with reduced motion (`prefers-reduced-motion: reduce`, or `OuroProvider`'s `motion`). |
| `windowing` | `boolean` | `true` | Whether a long conversation draws only the messages near its view. Above 60 children the list draws those within a screen or 800 pixels of the view, whichever is more, the newest, the one with focus, and any still streaming, each measured as it draws, with space for the rest kept above and below, so a long conversation opens and redraws in about the time a screenful does. The browser's find in page, and code that reads messages from the page, see only the messages drawn: search your own messages and call `actionsRef.current.reveal(id)` to show a match. Set `false` to keep every message in the page. A list the reader scrolled up in that takes older and newer messages in one commit renders no more than 60 of the children it drew before, and the 20 newest, before it finds the view; scrolling up through rows never drawn moves what the reader looks at by under a pixel in every frame; and a list opens at its end with no frame moving it more than a pixel. |

## Native attributes

Also accepts the attributes in React's `HTMLAttributes` (`id`, `aria-*`, event handlers), passed to the rendered element, except those named on this page.

## Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

- `autoScroll`: Not a prop. The list follows new messages while the reader is at the bottom, and stops when they scroll up.
- `stickToBottom`: Not a prop. The list follows new messages while the reader is at the bottom, and stops when they scroll up.

## `className` is for layout only

Pass margins, width and grid placement (`mt-4`, `w-full`, `col-span-2`). Your classes always win over Ouro UI's: MessageList merges `className` last with `cn` (tailwind-merge), so a class that conflicts with its own replaces it.[^layout-classes]

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (`hover:`, `sm:`) and any `!` are removed, it starts with `bg-`, `text-`, `border-`, `rounded-`, `shadow-`, `font-`, `ring-`, `from-`, `via-`, `to-`, `fill-` or `stroke-`, or is `border`, `rounded`, `shadow` or `ring`, or has a `dark:` variant. Text wrapping, overflow and table layout classes (`text-wrap`, `text-ellipsis`, `border-collapse`) are layout and do not warn.

Alignment classes (`text-center`) warn too: set alignment on a parent element.

## Types

### `MessageListActions`

What a MessageList does on request, through its `actionsRef`.

```ts
interface MessageListActions {
  /**
   * Brings the message whose `id` is given into view, drawing it first if the list has not, and
   * stops following new messages. A child is found by its `id` prop, or by its `key` (the message's
   * own id, where a row component of yours takes no `id`), and once drawn by the `id` of any element
   * in it. Resolves true once the message is in place (and focused, with `focus`), and false at once
   * when no child has that `id` or `key`, or when another reveal or a key press takes its place. Find
   * in chat searches your own messages, then reveals each match: the browser's find in page sees only
   * what is drawn.
   */
  reveal(id: string, options?: MessageListRevealOptions): Promise<boolean>;
  /**
   * Goes to the newest message and follows new messages again, as "Jump to latest" does, leaving focus where it is.
   */
  jumpToNewest(): void;
}
```

### `MessageListRevealBlock`

Where `reveal` puts a message: in the middle of the view, at its top, or scrolled only as far as it needs.

```ts
type MessageListRevealBlock = "center" | "start" | "nearest";
```

### `MessageListRevealOptions`

How `reveal` shows a message.

```ts
interface MessageListRevealOptions {
  /** Whether the message takes focus, as for a quote the reader pressed. @default false */
  focus?: boolean | undefined;
  /** Where in the view it goes. @default "center" */
  block?: MessageListRevealBlock | undefined;
}
```

[^layout-classes]: test:registry/ouro/components/ouro/message/message-list.test.tsx::MessageList > forwards a ref to the root and lets app layout classes win verify:testExists(registry/ouro/components/ouro/message/message-list.test.tsx::MessageList > forwards a ref to the root and lets app layout classes win)
