# Message scroller

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

## Anatomy

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](/message.html.md) 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?

 Message

Send

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

Load earlier messages

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

Jump to the opening question

```
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 |
