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.
| Input | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follows the bottom as content grows. |
defaultScrollPosition | 'start' | 'end' | 'last-anchor' | 'end' | Where a preloaded thread opens. |
scrollPreviousItemPeek | number | 64 | Pixels of the previous turn kept visible above an anchor. |
scrollMargin | number | 0 | Offset from the viewport edge when scrolling to a message, such as the height of a sticky header. |
scrollEdgeThreshold | number | 64 | Distance in pixels from the end within which scrolling down resumes following. |
| Method | Type | Description |
|---|---|---|
scrollToEnd(options?) | boolean | Scrolls to the end and resumes following when autoScroll is on. |
scrollToStart(options?) | boolean | Scrolls to the first message. |
scrollToMessage(id, options?) | boolean | Scrolls to a message by id with align, behavior and scrollMargin. Waits for the message to register when called before it renders. |
| Property | Type | Description |
|---|---|---|
scrollable() | { start: boolean; end: boolean } | Whether there is more to scroll, per edge: { start, end }. |
autoscrolling() | boolean | True during a smooth scroll made by the scroller. |
pendingScroll() | boolean | True until the default position is applied. |
currentAnchorId() | string | null | The anchor at the top of the viewport. |
visibleMessageIds() | string[] | The ids of the messages in view. |
buiMessageScrollerViewport
The scrolling region.
| Input | Type | Default | Description |
|---|---|---|---|
preserveScrollOnPrepend | boolean | true | Keeps 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.
| Input | Type | Default | Description |
|---|---|---|---|
messageId | string | Required | Identifies the message for scrolling and state. |
scrollAnchor | boolean | false | Scrolls 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.
| Input | Type | Default | Description |
|---|---|---|---|
direction | 'start' | 'end' | 'end' | The edge to jump to. A start button turns its icon upside down. |
behavior | ScrollBehavior | 'smooth' | Whether the jump is smooth or instant. |