sdxc

Type to search, or start from one of these:

MessageScroller

A scrollable frame for a conversational message log.

New chat

How can I help you today?

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/ui
import "@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.

  • MessageScroller
  • MessageScroller.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 via aria-relevant="additions".
  • MessageScroller.ItemRenders a single message row: a <div> mirroring messageId onto data-message-id and, when scrollAnchor is set, an empty data-scroll-anchor attribute 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 starting hidden until 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.

PropTypeDescription
childrenRemixNodeThe frame's compound parts: MessageScroller.Viewport and, optionally, MessageScroller.Button.

Also accepts everything in TagProps<"div">.

MessageScroller.Viewport

PropTypeDescription
children?RemixNodeMessageScroller.Content, scrolling inside the viewport.

Also accepts everything in TagProps<"div">.

MessageScroller.Content

PropTypeDescription
"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?RemixNodeThe MessageScroller.Item rows to render in document order.

Also accepts everything in TagProps<"div">.

MessageScroller.Item

PropTypeDescription
messageIdstringStable 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?booleanMarks 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?RemixNodeThe row's own content, typically a conversational row composed inside it.

Also accepts everything in TagProps<"div">.

MessageScroller.Button

PropTypeDescription
hidden?booleanWhether 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.