MessageScroller
A scrollable frame for a conversational message log.
New chat
Nothing here yet
Press send to start the conversation and watch the reply arrive.View code
let model = new ScrollFollowModel({ pinned: true });
let turns = [];
let streaming = false;
// A reply arrives a word at a time, which is what the viewport follows.
function send() {
let exchange = script[sent++];
let answerId = `answer-${sent}`;
turns = [
...turns,
{ id: `ask-${sent}`, from: "reader", body: exchange.ask },
{ id: answerId, from: "assistant", body: "" },
];
streaming = true;
void handle.update();
// Sending is the reader asking to be at the live edge, wherever they were reading.
model.scrollToEnd();
let words = exchange.answer.split(" ");
let written = 0;
timer = setInterval(() => {
written += 1;
turns = [
...turns.slice(0, -1),
{ id: answerId, from: "assistant", body: words.slice(0, written).join(" ") },
];
if (written >= words.length) {
clearInterval(timer);
streaming = false;
}
void handle.update();
}, 45);
}
<MessageScroller>
<MessageScroller.Viewport mix={[messageFollow(model), scrollFade({ axis: "block" })]}>
<MessageScroller.Content aria-busy={streaming || undefined}>
{turns.map((turn) => (
<MessageScroller.Item key={turn.id} messageId={turn.id} scrollAnchor mix={[p(3)]}>
{/* The reader's own turns hug the trailing edge in a frame; everyone else's
run unframed, so the answer reads as one column of prose. */}
<Bubble
variant={turn.from === "reader" ? "muted" : "ghost"}
align={turn.from === "reader" ? "end" : "start"}
>
<Bubble.Content>{turn.body}</Bubble.Content>
</Bubble>
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
{/* The control floats over the frame rather than scrolling inside it, so the
viewport's own edge fade stays on the conversation. */}
<MessageScroller.Button
aria-label="Jump to latest"
hidden={false}
data-scroll-jump
mix={[when("&:not([data-visible])", hidden())]}
>
<ArrowDownIcon />
</MessageScroller.Button>
</MessageScroller>Installation
An ordinary dependency: install it, import the theme once, and import the component where it is used.
npm add @sdxc/uiimport "@sdxc/ui/theme.css";Usage
import { MessageScroller } from "@sdxc/ui";Renders the frame's root host: a bordered, rounded <div> that
declares the ui-message-scroller container queried by
MessageScroller.Button for its own placement.
Composition
The parts the component publishes, each a static property of the host.
MessageScrollerMessageScroller.ViewportRenders the frame's scrolling surface as ScrollArea.Viewport, fixed to the block axis.MessageScroller.ContentRenders the frame's live region: a<div>carrying the ARIA log role, stacking its MessageScroller.Item rows in a column and announcing each added row viaaria-relevant="additions".MessageScroller.ItemRenders a single message row: a<div>mirroringmessageIdontodata-message-idand, whenscrollAnchoris set, an emptydata-scroll-anchorattribute for a scroll-follow behavior to read.MessageScroller.ButtonRenders a static jump-to-latest control as Button, floating over the frame's block-end edge and startinghiddenuntil a paired scroll-follow behavior clears the attribute.
Props
Read from the component's own types, so every prop, every default and every allowed value is listed.
| Prop | Type | Description |
|---|---|---|
children | RemixNode | The frame's compound parts: MessageScroller.Viewport and, optionally, MessageScroller.Button. |
Also accepts everything in TagProps<"div">.
MessageScroller.Viewport
| Prop | Type | Description |
|---|---|---|
children? | RemixNode | MessageScroller.Content, scrolling inside the viewport. |
Also accepts everything in TagProps<"div">.
MessageScroller.Content
| Prop | Type | Description |
|---|---|---|
"aria-busy"? | TagProps<"div">["aria-busy"] | Marks the log as busy while a row inside it is still being written to — a reply still streaming in, for instance — forwarded to the host's native aria-busy attribute. Clear it once the row settles. |
children? | RemixNode | The MessageScroller.Item rows to render in document order. |
Also accepts everything in TagProps<"div">.
MessageScroller.Item
| Prop | Type | Description |
|---|---|---|
messageId | string | Stable id of the message this row renders, mirrored onto the host's data-message-id attribute so a scroll-follow behavior can find, measure, and scroll to this exact row directly. |
scrollAnchor? | boolean | Marks this row as a candidate anchor point — typically the first row of a new turn — mirrored onto data-scroll-anchor so a scroll-follow behavior can tell which rows are worth anchoring the viewport to. |
children? | RemixNode | The row's own content, typically a conversational row composed inside it. |
Also accepts everything in TagProps<"div">.
MessageScroller.Button
| Prop | Type | Description |
|---|---|---|
hidden? | boolean | Whether the control is absent from rendering and the accessibility tree. A paired scroll-follow behavior clears this attribute as the reader scrolls away from the live edge and sets it again on return or press. |
Also accepts everything in Button.Props.