The scrolling region of a conversation. It follows new messages while the reader is at the bottom, stays put when the reader has scrolled up (with a Jump to latest button), and keeps the place when older messages are added at the top.
Bring your own model.
These components only show what you give them, in one message shape, and report what the person does. They never call a model or a server, so they work with any backend. They are built for how assistive technology handles streaming text, tool steps and approvals.
import { useState } from "react";
import { Button, Message, MessageScroller, type ChatMessage } from "@rdloom/react";
const replies = [
"Your order shipped this morning.",
"It should arrive on Thursday.",
"I can change the delivery address until it leaves the depot.",
"Anything else I can check for you?",
];
const first: ChatMessage[] = [
{ id: "m1", role: "user", parts: [{ type: "text", text: "Where is my order?" }] },
{ id: "m2", role: "assistant", parts: [{ type: "text", text: "Let me look that up." }] },
];
export default function MessageScrollerLiveConversationExample() {
const [messages, setMessages] = useState(first);
const add = () =>
setMessages((prev) => {
const n = prev.length;
const mine = n % 2 === 0;
const text = mine ? "Can you change the address?" : replies[(n - 1) / 2 % replies.length | 0];
return [...prev, { id: `m${n + 1}`, role: mine ? "user" : "assistant", parts: [{ type: "text", text }] }];
});
return (
<div className="flex w-full justify-center py-4">
<div className="flex w-[36rem] max-w-full flex-col gap-4">
<div className="h-72 rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)]">
<MessageScroller label="Order support">
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</MessageScroller>
</div>
<div className="flex justify-center">
<Button variant="secondary" size="sm" onPress={add}>
Add a message
</Button>
</div>
</div>
</div>
);
}Installation
npx rdloom add message-scrollerCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/message-scroller.jsonWorks, but without upgrade tracking.
Usage
import { Button, Message, MessageScroller, type ChatMessage } from "@rdloom/react";
<div className="flex w-full justify-center py-4">
<div className="flex w-[36rem] max-w-full flex-col gap-4">
<div className="h-72 rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)]">
<MessageScroller label="Order support">
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</MessageScroller>
</div>
<div className="flex justify-center">
<Button variant="secondary" size="sm" onPress={add}>
Add a message
</Button>
</div>
</div>
</div>Load older
import { useState } from "react";
import { Message, MessageScroller, type ChatMessage } from "@rdloom/react";
const make = (from: number, to: number): ChatMessage[] =>
Array.from({ length: to - from }, (_, i) => {
const n = from + i;
return { id: `m${n}`, role: n % 2 ? "assistant" : "user", parts: [{ type: "text", text: `Message ${n} in this conversation.` }] } as ChatMessage;
});
export default function MessageScrollerLoadOlderExample() {
const [messages, setMessages] = useState(() => make(21, 31));
const [loading, setLoading] = useState(false);
const oldest = Number(messages[0].id.slice(1));
const loadOlder = () => {
if (oldest <= 1) return;
setLoading(true);
setTimeout(() => {
setMessages((prev) => [...make(Math.max(1, oldest - 10), oldest), ...prev]);
setLoading(false);
}, 700);
};
return (
<div className="flex w-full justify-center py-4">
<div className="h-72 w-[36rem] max-w-full rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)]">
<MessageScroller label="History" onReachTop={loadOlder} isLoadingOlder={loading}>
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</MessageScroller>
</div>
</div>
);
}Unread count
import { useEffect, useRef, useState } from "react";
import { Message, MessageScroller, type ChatMessage } from "@rdloom/react";
const seed: ChatMessage[] = Array.from({ length: 12 }, (_, i) => ({
id: `m${i + 1}`,
role: i % 2 ? "assistant" : "user",
parts: [{ type: "text", text: `Earlier message ${i + 1}.` }],
}));
export default function MessageScrollerUnreadCountExample() {
const [messages, setMessages] = useState(seed);
const [unread, setUnread] = useState(0);
const atBottom = useRef(true);
// A new message arrives every few seconds; scroll up in the box to see the count grow.
useEffect(() => {
const timer = setInterval(() => {
setMessages((prev) => [...prev, { id: `m${prev.length + 1}`, role: "assistant", parts: [{ type: "text", text: `New message ${prev.length + 1}.` }] }]);
if (!atBottom.current) setUnread((n) => n + 1);
}, 3000);
return () => clearInterval(timer);
}, []);
return (
<div className="flex w-full justify-center py-4">
<div className="h-72 w-[36rem] max-w-full rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)]">
<MessageScroller label="Team thread" unreadCount={unread}
onAtBottomChange={(v) => {
atBottom.current = v;
if (v) setUnread(0);
}}
>
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</MessageScroller>
</div>
</div>
);
}API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
childrenrequiredThe messages, in order, oldest first. | node | none |
labelAccessible name of the log. | string | "Conversation" |
unreadCountHow many new messages arrived while the reader was scrolled up. Shown on the Jump to latest button. | number | none |
onReachTopCalled when the reader scrolls to the top, so older messages can be loaded. The scroll position is kept when they are added. | () => void | none |
isLoadingOlderOlder messages are being loaded. Shows a status line at the top and pauses onReachTop. | boolean | false |
thresholdHow many pixels from the bottom still count as being at the bottom. | number | 80 |
onAtBottomChangeCalled when the reader moves away from the bottom or back to it. Use it to count and clear unread messages. | (isAtBottom: boolean) => void | none |
onJumpToLatestCalled after the reader presses Jump to latest, e.g. to clear the unread count. | () => void | none |
Accessibility
Role log, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab moves to the region, then Arrow keys, Page Up, Page Down, Home and End scroll it
- Tab then reaches the Jump to latest button; Enter or Space scrolls to the newest message
Screen readers announce
- The region is announced as a log with its label
- New messages are read after the current speech
- The button is announced as, for example, Jump to latest, 3 new messages
What your code must do
- The region has role log, aria-live polite and an accessible name from label
- The region is focusable so keyboard users can scroll it
- New messages are announced politely; the reader is never moved if they scrolled up
- Smooth scrolling is turned off when the person prefers reduced motion
- The Jump to latest button names the unread count
Guidelines
Use it when
- A conversation or activity feed that grows at the bottom
- A chat where older messages load at the top
Avoid it when
- A full chat with a message box: use Chat
- A static list that does not grow: use ScrollArea
Don't
- Forcing the reader to the bottom when they scrolled up on purpose
- Giving the region no height, so it never scrolls
- Streaming tokens into a polite live region without a quiet period
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-raised--rd-color-text-default--rd-color-text-muted--rd-color-border-strong--rd-color-action-primary--rd-color-action-on-primary--rd-color-focus-ring--rd-elevation-floating