Skip to main content

Chat

MessageList

A conversation: Messages 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.

Ana Lind
How long should the dough rest?
Ouro
About an hour, somewhere warm.
Ana Lind
Can it rest in the fridge?
Ouro
Yes, overnight in the fridge works too.

On this site

This site's own pages use MessageList too. Open one with X-ray pointing it out: The Nautilus's story

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/message-list#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/message-list

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/message-list#v1.3.10 ourostack/ouro-ui/message-list-tests#v1.3.10
import { MessageList } from "@/components/ouro/message/message-list";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<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 Messages, 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.

actionsRef
Type: 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)
Type: ReactNode
The Messages, oldest first, or your own message components marked data-ouro-message-list-item (see MessageList).
className
Type: 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
Type: 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
Type: boolean
Default: 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
Type: 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
Type: 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
Type: string
Default: "Jump to latest"
The button that appears once the reader scrolls up, for other languages.
label (required)
Type: string
What the conversation is, for screen readers: "Conversation with Ouro". Required, so the region has a name.
loadingOlder
Type: 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
Type: string
Default: "Loading older messages"
What shows while older messages load, for other languages.
maxWidth
Type: 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
Type: ((author: string, text: string) => string)
Default: (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
Type: ((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
Type: boolean
Default: 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
Type: Ref<HTMLDivElement>
Reaches the rendered element.
springy
Type: boolean
Default: 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
Type: boolean
Default: 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.

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.

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

MessageListRevealOptions

How reveal shows a message.

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;
}

Claim sources