Skip to content

ExpressiveCSS

Browse documentation

Message scroller

A chat scroll container that anchors turns, follows streamed replies and keeps its place as history loads.

View as Markdown

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.

Can you check why the install fails on CI?

Sure. Which package manager does the workspace use?

pnpm, with a lockfile from last week.

Then the lockfile is probably out of date. The workspace package was renamed on Monday.

That would explain it. What should I run?

Run pnpm install at the root and commit the new lockfile.

Done. CI is green again.

Good. Anything else?

<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-padding16px
--md-comp-message-scroller-gap16px

No matching pages

  • Source color

    The seed every generated ramp derives from. Pick one and browse the docs — the whole theme follows. Error does not: it is a fixed hue.

    Default source: Mist Iris.

    ExpressiveCSS presets: Lagoon, Harbor, Breeze, Cobalt, Iris.

    Bold

    Soft

    Mist

    Glow