Skip to content

Message Scroller

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.

AIv0.1.0experimentalWCAG 2.2 AAView spec

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.

Where is my order?

Let me look that up.

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-scroller

Copies 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.json

Works, 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

Message 21 in this conversation.

Message 22 in this conversation.

Message 23 in this conversation.

Message 24 in this conversation.

Message 25 in this conversation.

Message 26 in this conversation.

Message 27 in this conversation.

Message 28 in this conversation.

Message 29 in this conversation.

Message 30 in this conversation.

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

Earlier message 1.

Earlier message 2.

Earlier message 3.

Earlier message 4.

Earlier message 5.

Earlier message 6.

Earlier message 7.

Earlier message 8.

Earlier message 9.

Earlier message 10.

Earlier message 11.

Earlier message 12.

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.

PropTypeDefault
childrenrequired

The messages, in order, oldest first.

nodenone
label

Accessible name of the log.

string"Conversation"
unreadCount

How many new messages arrived while the reader was scrolled up. Shown on the Jump to latest button.

numbernone
onReachTop

Called when the reader scrolls to the top, so older messages can be loaded. The scroll position is kept when they are added.

() => voidnone
isLoadingOlder

Older messages are being loaded. Shows a status line at the top and pauses onReachTop.

booleanfalse
threshold

How many pixels from the bottom still count as being at the bottom.

number80
onAtBottomChange

Called when the reader moves away from the bottom or back to it. Use it to count and clear unread messages.

(isAtBottom: boolean) => voidnone
onJumpToLatest

Called after the reader presses Jump to latest, e.g. to clear the unread count.

() => voidnone

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