sdxc

[ @sdxc/ui ]

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 remix
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.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.