Message Scroller Since v4.1.0

The thread scroll container that follows streaming replies and anchors new turns.

Usage

The directives are standalone. The viewport, content, item and button require the root.

import {
BuiMessageScroller,
BuiMessageScrollerButton,
BuiMessageScrollerContent,
BuiMessageScrollerItem,
BuiMessageScrollerViewport,
MessageScrollerPosition,
} from '@/bui/ui/message-scroller';
<div buiMessageScroller>
<div buiMessageScrollerViewport>
<div buiMessageScrollerContent>
<div buiMessageScrollerItem messageId="1">...</div>
<div buiMessageScrollerItem messageId="2">...</div>
</div>
</div>
<button matButton buiMessageScrollerButton>...</button>
</div>

Example

A new question scrolls to the top of the thread with the end of the previous turn peeking above it, and the reply streams in beneath. Once the reply outgrows the viewport, the thread follows it. Scroll up while it streams to stop following; the button jumps back to the end.

<div buiMessageScroller autoScroll class="h-80">
<div buiMessageScrollerViewport>
<div buiMessageScrollerContent class="gap-6 px-4 text-lg">
@for (message of messages(); track message.id) {
<div
buiMessageScrollerItem
[messageId]="message.id"
[scrollAnchor]="message.from === 'user'"
>
<div buiMessage [align]="message.from === 'user' ? 'end' : 'start'">
<div buiMessageContent>
@if (message.from === 'user') {
<div buiMessageBubble>{{ message.text }}</div>
} @else {
<div
buiStreamingMarkdown
[text]="message.text"
[streaming]="streaming() && $last"
></div>
}
</div>
</div>
</div>
}
</div>
</div>
<button
matButton
buiMessageScrollerButton
class="icon-only"
aria-label="Scroll to end"
>
<mat-icon svgIcon="arrow-down" />
</button>
</div>

Following

With autoScroll, the thread stays pinned to the bottom as content grows. Scrolling up releases following and scrolling back down within the threshold restores it, so the reader can look back without being pulled down. Jumping to the end resumes following. While following, the end button stays hidden. The viewport must not use scroll-smooth, since the scroller recognizes its own scrolls by their target position.

Scroll anchors

An item marked scrollAnchor, usually each user turn, scrolls to the top of the viewport when it is added, with scrollPreviousItemPeek pixels of the previous turn still visible. The space below the reply shrinks as the reply fills it, and following takes over once the reply is taller than the viewport.

Default position

defaultScrollPosition sets where a preloaded thread opens: start, end, or last-anchor, which opens at the last user turn and falls back to end when everything from that turn down fits in the viewport. The viewport stays hidden until the position is applied, so the thread never flashes at the top.

<div buiMessageScroller defaultScrollPosition="last-anchor">
...
</div>

Loading history

When older messages are added above, the message in view keeps its place. Set preserveScrollOnPrepend to false on the viewport to opt out.

<div buiMessageScroller>
<div buiMessageScrollerViewport preserveScrollOnPrepend>
...
</div>
</div>
loadOlder(older: Message[]) {
this.messages.update((messages) => [...older, ...messages]);
}

Programmatic control

Grab a template reference via #ref="buiMessageScroller" to scroll from code and to read the scroll state.

Anchor none · Visible

<p>
Anchor {{ thread.currentAnchorId() ?? 'none' }} ·
Visible {{ thread.visibleMessageIds().join(', ') }}
</p>
<button matButton (click)="thread.scrollToStart({ behavior: 'smooth' })">
Start
</button>
<button matButton (click)="thread.scrollToEnd({ behavior: 'smooth' })">
End
</button>
@for (turn of questions; track turn.id) {
<button matButton (click)="thread.scrollToMessage(turn.id, { align: 'start', behavior: 'smooth' })">
Q{{ $index + 1 }}
</button>
}

<div buiMessageScroller #thread="buiMessageScroller">
...
</div>

API Reference

buiMessageScroller

Root. Owns the scroll state and sizes to its container.

InputTypeDefaultDescription
autoScrollbooleanfalseFollows the bottom as content grows.
defaultScrollPosition'start' | 'end' | 'last-anchor''end'Where a preloaded thread opens.
scrollPreviousItemPeeknumber64Pixels of the previous turn kept visible above an anchor.
scrollMarginnumber0Offset from the viewport edge when scrolling to a message, such as the height of a sticky header.
scrollEdgeThresholdnumber64Distance in pixels from the end within which scrolling down resumes following.
MethodTypeDescription
scrollToEnd(options?)booleanScrolls to the end and resumes following when autoScroll is on.
scrollToStart(options?)booleanScrolls to the first message.
scrollToMessage(id, options?)booleanScrolls to a message by id with align, behavior and scrollMargin. Waits for the message to register when called before it renders.
PropertyTypeDescription
scrollable(){ start: boolean; end: boolean }Whether there is more to scroll, per edge: { start, end }.
autoscrolling()booleanTrue during a smooth scroll made by the scroller.
pendingScroll()booleanTrue until the default position is applied.
currentAnchorId()string | nullThe anchor at the top of the viewport.
visibleMessageIds()string[]The ids of the messages in view.

buiMessageScrollerViewport

The scrolling region.

InputTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeeps the message in view in place when older messages load above.

buiMessageScrollerContent

Holds the messages and the spacer below the last one.

buiMessageScrollerItem

Registers one message by its messageId.

InputTypeDefaultDescription
messageIdstring Required Identifies the message for scrolling and state.
scrollAnchorbooleanfalseScrolls to the top of the viewport when added.

buiMessageScrollerButton

Jumps to the start or the end. Shown only while there is more to scroll in its direction.

InputTypeDefaultDescription
direction'start' | 'end''end'The edge to jump to. A start button turns its icon upside down.
behaviorScrollBehavior'smooth'Whether the jump is smooth or instant.