Message Scroller
Displays a scrolling conversation that stays on the newest message as it arrives
<div class="h-[28rem] w-full">
<sa-message-scroller>
<sa-message-scroller-viewport aria-label="Conversation with Mika Tanaka">
<sa-message-scroller-content class="px-1 py-2">
<sa-message-scroller-item>
<sa-marker variant="MarkerVariant.Separator">
<sa-marker-content>Tuesday, 12 April</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-message-header>Mika Tanaka</sa-message-header>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
Morning! Your Kyoto itinerary is ready - 12 to 19 April.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
Wonderful. Does it still include the Arashiyama day trip?
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
It does - the bamboo grove first thing, then the Sagano railway at 14:00.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="ticket"/>
</sa-marker-icon>
<sa-marker-content>E-tickets issued for 2 travellers</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
Perfect. Could you add a vegetarian meal on both flights?
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
Added to both. The airline confirms special meals 48 hours before departure.
</sa-bubble-content>
</sa-bubble>
<sa-message-footer>16:42</sa-message-footer>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
</sa-message-scroller-content>
</sa-message-scroller-viewport>
<sa-message-scroller-button/>
</sa-message-scroller>
</div>Usage
A message scroller is composed of <sa-message-scroller> wrapping a <sa-message-scroller-viewport>, a <sa-message-scroller-content> holding one <sa-message-scroller-item> per row, and any <sa-message-scroller-button> controls.
<sa-message-scroller>
<sa-message-scroller-viewport>
<sa-message-scroller-content>
<sa-message-scroller-item>
...
</sa-message-scroller-item>
</sa-message-scroller-content>
</sa-message-scroller-viewport>
<sa-message-scroller-button/>
</sa-message-scroller>The scroller fills its container rather than sizing itself, so give it a parent with a height — a fixed height, or a flex child with min-h-0. Without one it has nothing to scroll inside.
The transcript opens on the newest message and stays there as messages arrive. As soon as the reader scrolls away it stops following, so nothing is pulled out from under them while they read back; returning to the newest message takes following up again.
New messages are picked up from the DOM rather than from any particular transport, so it behaves the same whether they arrive from an htmx swap, server-sent events, SignalR or your own fetch. Append them to the <sa-message-scroller-content> element and the scroller does the rest.
What it does not do
The scroller deliberately stops short of the behaviour a dedicated chat client needs. It does not anchor each new turn to the top of the viewport, it does not preserve the reader's position when older history is prepended above them, and it does not restore the position a reader left off at. If you need those, drive the scroll position yourself with scrollToStart() and scrollToEnd().
Examples
Initial position
Set initial-position to Start to open on the oldest message instead of the newest — the right choice when the reader is coming to read a thread from the beginning rather than to catch up on it. Pair it with auto-scroll="false" so a message arriving mid-read does not pull them to the bottom.
<div class="h-80 w-full">
<sa-message-scroller initial-position="MessageScrollerPosition.Start" auto-scroll="false">
<sa-message-scroller-viewport aria-label="Booking history">
<sa-message-scroller-content class="px-1 py-2">
<sa-message-scroller-item>
<sa-marker variant="MarkerVariant.Separator">
<sa-marker-content>Where this booking started</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
We are two travellers looking at Kyoto in April, ideally during the blossom.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
The second week of April is the safest bet. Shall I hold two seats on NH212?
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
Please do. We can confirm the ryokan once the flights are held.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="plane-takeoff"/>
</sa-marker-icon>
<sa-marker-content>Seats held on NH212, London to Tokyo</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
Held until Friday. The Kanra Kyoto has a garden room free for the same nights.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
Take it. We would rather be near the river than close to the station.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="hotel"/>
</sa-marker-icon>
<sa-marker-content>Garden room confirmed, 12 to 14 April</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
That is the whole trip booked. I will send the itinerary tomorrow morning.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
</sa-message-scroller-content>
</sa-message-scroller-viewport>
<sa-message-scroller-button/>
</sa-message-scroller>
</div>Scroll directions
A <sa-message-scroller-button> defaults to scrolling to the end. Set direction to Start for one that scrolls back to the top, and include both where a transcript is long enough to get lost in. Each button fades out and stops taking clicks when there is nothing left to scroll to in its direction.
The buttons carry an sr-only label describing where they scroll to, so they are announced even though they render as icons.
<div class="h-80 w-full">
<sa-message-scroller>
<sa-message-scroller-viewport aria-label="Trip notes">
<sa-message-scroller-content class="px-1 py-2">
<sa-message-scroller-item>
<sa-marker variant="MarkerVariant.Separator">
<sa-marker-content>Trip notes</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="hotel"/>
</sa-marker-icon>
<sa-marker-content>Two nights at the Kanra Kyoto, 12 to 14 April</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="map-pin"/>
</sa-marker-icon>
<sa-marker-content>Arashiyama bamboo grove, early morning on the 13th</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="ticket"/>
</sa-marker-icon>
<sa-marker-content>Sagano railway, 14:00 departure, carriage 3</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="credit-card"/>
</sa-marker-icon>
<sa-marker-content>Balance of EUR 1,240 due by 28 March</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="plane-takeoff"/>
</sa-marker-icon>
<sa-marker-content>Return NH211 departs Haneda 09:55 on the 19th</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="file-text"/>
</sa-marker-icon>
<sa-marker-content>Travel insurance certificate attached to the booking</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="map-pin"/>
</sa-marker-icon>
<sa-marker-content>Fushimi Inari at first light on the 15th, guide booked</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="user"/>
</sa-marker-icon>
<sa-marker-content>Vegetarian meals requested for both travellers</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker>
<sa-marker-icon>
<sa-icon name="ticket"/>
</sa-marker-icon>
<sa-marker-content>Airport transfer at 09:15, driver details to follow</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-marker variant="MarkerVariant.Separator">
<sa-marker-content>End of notes</sa-marker-content>
</sa-marker>
</sa-message-scroller-item>
</sa-message-scroller-content>
</sa-message-scroller-viewport>
<sa-message-scroller-button direction="MessageScrollerDirection.Start"/>
<sa-message-scroller-button direction="MessageScrollerDirection.End"/>
</sa-message-scroller>
</div>New messages
Appending to the content element is all that is needed for the transcript to follow along. The viewport is a labelled region that a keyboard user can focus and scroll, and the content is a log with aria-relevant="additions", so a screen reader announces messages as they arrive without re-reading the transcript already on screen. Give the viewport an aria-label naming the conversation when a page holds more than one.
<div class="flex h-[26rem] w-full flex-col gap-3">
<div class="min-h-0 flex-1">
<sa-message-scroller>
<sa-message-scroller-viewport aria-label="Conversation with Mika Tanaka">
<sa-message-scroller-content id="--message-scroller-transcript" class="px-1 py-2">
<sa-message-scroller-item>
<sa-message align="MessageAlign.End">
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-1.jpg" name="Priya Raman"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble>
<sa-bubble-content>
Could you check whether the Sagano railway still has seats on the 14:00?
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content>
Checking with the operator now - one moment.
</sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
</sa-message-scroller-content>
</sa-message-scroller-viewport>
<sa-message-scroller-button/>
</sa-message-scroller>
</div>
<div class="flex justify-center">
<sa-button id="--message-scroller-receive" variant="ButtonVariant.Outline" size="ButtonSize.Small">
<sa-icon name="message-circle"/>
Receive a message
</sa-button>
</div>
</div>
<template id="--message-scroller-template">
<sa-message-scroller-item>
<sa-message>
<sa-message-avatar>
<sa-avatar src="/avatars/avatar-2.jpg" name="Mika Tanaka"/>
</sa-message-avatar>
<sa-message-content>
<sa-bubble variant="BubbleVariant.Secondary">
<sa-bubble-content></sa-bubble-content>
</sa-bubble>
</sa-message-content>
</sa-message>
</sa-message-scroller-item>
</template>
<script>
(() => {
const transcript = document.getElementById("--message-scroller-transcript");
const template = document.getElementById("--message-scroller-template");
const replies = [
"Two seats are still open on the 14:00 - carriage 3.",
"Held them under booking VYG-4471-KX for the next hour.",
"E-tickets are on their way to your inbox now.",
"Anything else you would like me to sort out before you fly?",
];
let next = 0;
// Appending to the transcript is all the scroller needs: it watches the DOM, so the
// same code works whether the markup comes from a fetch, an htmx swap or a template.
document.getElementById("--message-scroller-receive").addEventListener("click", () => {
const item = template.content.cloneNode(true);
item.querySelector('[data-slot="bubble-content"]').textContent = replies[next % replies.length];
transcript.append(item);
next++;
});
})();
</script>Without JavaScript
The scroller is progressively enhanced. Without stellar-admin.js the transcript still lays out and scrolls normally; it opens on the oldest message rather than the newest, does not follow new content, and the buttons stay hidden and unclickable rather than appearing and doing nothing.
API Reference
<sa-message-scroller>
Renders a <sel-message-scroller> element, forwarding any global attributes. An id is generated when you do not supply one, so the buttons inside it can target it.
Prop
Type
The rendered element exposes scrollToStart() and scrollToEnd() methods, and a following property reporting whether the transcript is currently following new content.
<sa-message-scroller-viewport>
Renders the scrolling <div> element, forwarding any global attributes. It is a focusable region labelled Messages; set aria-label or aria-labelledby to name the conversation yourself.
<sa-message-scroller-content>
Renders a <div> element holding the transcript, forwarding any global attributes. It is a log with aria-relevant="additions" unless you set role or aria-relevant yourself. Append new messages here.
<sa-message-scroller-item>
Renders a <div> element wrapping one row of the transcript, forwarding any global attributes. Rows outside the viewport are skipped during rendering, which is what keeps a long transcript cheap, so wrap every row rather than only some.
<sa-message-scroller-button>
Renders a <button> element that scrolls the transcript, forwarding any global attributes. It defaults to an arrow icon and an sr-only label; supply your own content to replace both. The type defaults to button so a button inside a form does not submit it.
Prop
Type