Use a .message-scroller to scroll a conversation. While the
reader is at the end, the scroller follows new output. Scrolling up stops it.
When you add messages above, the reader keeps their place.
Put a .message-scroller-viewport and an
optional .message-scroller-button in the frame. Every child of the
viewport is one item, usually a message
or a message group. The frame fills its parent, so give the parent a
height. AutoInit() starts every
.message-scroller.
<div class="message-scroller">
<div class="message-scroller-viewport" role="log" aria-label="Conversation"
aria-relevant="additions" tabindex="0">
<div id="m1" class="message end" data-scroll-anchor>
<p class="message-bubble">Can you check why the install fails on CI?</p>
</div>
<div id="m2" class="message">
<p class="message-bubble">Sure. Which package manager does the workspace use?</p>
</div>
</div>
<button type="button" class="icon-button tonal message-scroller-button"
aria-label="Scroll to latest message">
<span class="material-symbols" aria-hidden="true">arrow_downward</span>
</button>
</div>
The viewport is a named, focusable scroll region, so the arrow keys
scroll it. If the conversation receives messages, make the viewport the log.
role="log" with aria-relevant="additions"
announces new messages. The button is inert while the viewport is at
the end. If the button had focus, focus moves to the viewport.
Turns
Mark the message that starts a turn with
data-scroll-anchor. Usually that is each of the user's
messages. Anchors are not tied to the sender. In a group chat, a
"joined the conversation" notice can start a turn too. When you append an
anchor, the viewport scrolls it to the top and leaves 64px of
the previous message in view. Blank space below the anchor makes room
for the reply and shrinks as the reply grows.
const viewport = document.querySelector('.message-scroller-viewport');
viewport.insertAdjacentHTML('beforeend', `
<div id="m3" class="message end" data-scroll-anchor>
<p class="message-bubble">pnpm, with a lockfile from last week.</p>
</div>`);
Streaming
Append the reply and write into it as it streams. Once the reply
passes the bottom of the viewport, the viewport follows it. Scrolling
toward the start stops following. That covers the wheel, touch, the scrollbar and the
↑, Page Up and Home keys.
Following resumes when the reader reaches the end again or presses the button.
Set aria-busy="true" on the log while a reply
streams. A screen reader then announces the reply once it is complete, instead
of word by word.
const reply = document.createElement('div');
reply.className = 'message';
reply.innerHTML = '<p class="message-bubble"></p>';
viewport.setAttribute('aria-busy', 'true');
viewport.append(reply);
for await (const chunk of response) reply.firstElementChild.append(chunk);
viewport.removeAttribute('aria-busy');
Saved transcripts
Set defaultScrollPosition to choose where a transcript opens:
'end', 'start', or
'last-anchor'. 'last-anchor' opens on the start of the
last turn, so the reader reads a long answer from its question. When that
turn fits in the viewport, the transcript opens at the end. If the viewport is
hidden when it starts, for example in a closed dialog, it opens there when it
is first shown.
<div class="message-scroller no-autoinit">…</div>
<script>
Expressive.MessageScroller.init(document.querySelector('.message-scroller'), {
defaultScrollPosition: 'last-anchor'
});
</script>
Loading history
Insert older messages at the start of the viewport. The message the
reader was looking at stays where it was. Anchors in the
inserted history do not scroll. Load more when the reader nears the
top. For example, use an IntersectionObserver on the
first message or a button above the conversation.
viewport.insertAdjacentHTML('afterbegin', olderMessagesHtml);
Jump to a message
Call scrollToMessage(id) to scroll the element with that id
to the top of the viewport, below the peek. Following stops. The method
returns false when the viewport holds no such element.
const scroller = Expressive.MessageScroller.getInstance(document.querySelector('.message-scroller'));
scroller.scrollToMessage('m1');
Options
| Name | Type | Default | Description |
|---|---|---|---|
autoScroll |
Boolean | true |
If true, the viewport follows new content while the reader is at the end, and scrolls a newly appended anchor to the top. |
defaultScrollPosition |
String | 'end' |
Where the transcript opens: 'end', 'start', or 'last-anchor'. 'last-anchor' opens at the end when the last turn fits. |
peek |
Number | 64 |
Pixels of the previous message left in view above an anchored turn or a message reached with scrollToMessage(). |
Methods
const scroller = Expressive.MessageScroller.getInstance(document.querySelector('.message-scroller'));
scroller.scrollToEnd(); // scrolls to the latest message and follows again
scroller.scrollToStart();
scroller.scrollToMessage('m42'); // false when no such message is in the viewport
scroller.destroy(); // removes listeners, observers and the spacer
Scrolling is smooth unless the reader prefers reduced motion.
Tokens
Override these on the .message-scroller.
| Token | Default |
|---|---|
--md-comp-message-scroller-padding | 16px |
--md-comp-message-scroller-gap | 16px |