MessageScroller
The transcript viewport — a virtualized list of turns, anchored to the newest one, that reports the band the reader has in front of them.
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";import { MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 360px; background: Tokens.color-background;
property <[MessageItem]> turns: [ { role: MessageRole.user, id: "t1", text: "Why is the parser dropping spans?" }, { role: MessageRole.agent, id: "t2", text: "`fold_expr` rebuilds the node and never carries the span over." }, { role: MessageRole.user, id: "t3", text: "Does that affect the diagnostics too?" }, { role: MessageRole.agent, id: "t4", text: "Every diagnostic under a folded expression points at the file rather than at the line." }, { role: MessageRole.user, id: "t5", text: "Fix it and add a test." }, { role: MessageRole.agent, id: "t6", text: "Done. The test folds a nested call and asserts the span survives." }, { role: MessageRole.user, id: "t7", text: "Run the whole suite." }, { role: MessageRole.agent, id: "t8", text: "212 passed, 0 failed." }, ];
VerticalLayout { padding: 20px;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; clip: true;
MessageScroller { messages: root.turns; } } }}Usage
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";
export component AppWindow inherits Window { // The transcript, oldest turn first. The model is yours; what the // scroller follows is its growth. in property <[MessageItem]> turns;
VerticalLayout { transcript := MessageScroller { messages: root.turns; // The band the reader has in front of them, as positions in // `messages` — mark them read, fire receipts, follow along in a // side index. changed last-visible-turn => { debug("read up to turn ", transcript.last-visible-turn); } } }}A long conversation is a row model, not a layout. The scroller renders one MessageBubble per turn through Glint’s virtualized list, so only the turns in view are instantiated and a ten-thousand-turn transcript costs what a ten-turn one does.
A virtualized row cannot own a slot — Slint gives a component one @children
and the scroller spends it on nothing — so a turn is role and text and no
more, the same trade DataTable makes for its
rows. Compose MessageBubble by hand inside a
ScrollArea when a turn needs a code block or a
layout of its own.
MessageItem is a data type rather than a component:
| Field | What it is |
|---|---|
role | Who authored the turn — it tints the bubble and picks its side |
text | What the turn says, and what the bubble wraps |
id | A stable name for the turn, reported back as anchor-id |
anchor | Marks the turn the viewport should land on when it arrives |
Sought by position, reported by name. The commands take a position — scroll-to-turn(index) — and what comes back
is named: anchor-id is the turn at the top of the viewport. The asymmetry is
not a preference (ADR-0033). Slint’s expression language has no loop, so an
array cannot be searched, and a virtualized list has no rows to walk — so
resolving your own id to a position is yours, and you are the one holding the
model. Reporting is the reverse: the turns on screen are the turns that exist,
so each can answer for itself.
That is why id earns its place on MessageItem. A position is not a stable
name for a turn once history lands above it, and read receipts, a side index
and “jump back to where I was” all need one that is.
Examples
The pin
The viewport is anchored to the newest turn. While pinned holds, every turn
appended to messages scrolls the view down to keep the newest one in sight;
scrolling back through the history releases it, and the jump-to-latest control
— or scrolling back down — takes it again. The control exists only while it
has something to do, so it leaves the tab order and the accessibility tree the
moment the viewport is pinned again.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";import { MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 420px; background: Tokens.color-background;
in-out property <bool> replied: false;
// Two prepared transcripts rather than one that grows: Slint arrays do // not grow and one cannot be built from another, so a preview appends a // turn by handing the scroller the longer of the two. property <[MessageItem]> asked: [ { role: MessageRole.user, id: "t1", text: "Start the release checklist." }, { role: MessageRole.agent, id: "t2", text: "Checking the tags first." }, { role: MessageRole.user, id: "t3", text: "Anything on main since v0.1.0?" }, { role: MessageRole.agent, id: "t4", text: "Eleven commits, none of them touching the public API." }, { role: MessageRole.user, id: "t5", text: "Good. Draft the notes." }, ]; property <[MessageItem]> answered: [ { role: MessageRole.user, id: "t1", text: "Start the release checklist." }, { role: MessageRole.agent, id: "t2", text: "Checking the tags first." }, { role: MessageRole.user, id: "t3", text: "Anything on main since v0.1.0?" }, { role: MessageRole.agent, id: "t4", text: "Eleven commits, none of them touching the public API." }, { role: MessageRole.user, id: "t5", text: "Good. Draft the notes." }, { role: MessageRole.agent, id: "t6", text: "Drafted. Two sections: the parser fix and the docs site." }, ];
VerticalLayout { padding: 20px; spacing: 12px;
Rectangle { // A fixed viewport: the transcript's height must not depend on // the line under it that reports what the transcript is doing. height: 240px; border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; clip: true;
transcript := MessageScroller { messages: root.replied ? root.answered : root.asked; } }
HorizontalLayout { spacing: 12px; alignment: start;
Button { variant: ButtonVariant.outline; text: root.replied ? "Take the reply back" : "Append a reply"; clicked => { root.replied = !root.replied; } }
Text { text: transcript.pinned ? "Pinned — an arriving turn scrolls the view down." : "Released — the view stays where you left it."; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; vertical-alignment: center; } } }}Seeking to a turn
jump-to-latest(), scroll-to-start() and scroll-to-turn(index) are the
three things the reader does not drive. A seek is not the live edge, so it
releases the pin — which is what puts the jump-to-latest control on screen, the
reader’s way back — and the reader scrolling cancels whatever you had in
flight, because taking the view over is a clearer statement of intent than any
command.
A seek is a walk rather than a jump: rows are bubbles, so they are not a fixed height and there is no arithmetic from an index to an offset. The scroller estimates off the turn it has measured and then steers by whichever turn reports itself standing at the top of the viewport.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";import { MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 440px; background: Tokens.color-background;
property <[MessageItem]> turns: [ { role: MessageRole.user, id: "t1", text: "Turn 1 — the beginning of the conversation." }, { role: MessageRole.agent, id: "t2", text: "Turn 2." }, { role: MessageRole.user, id: "t3", text: "Turn 3." }, { role: MessageRole.agent, id: "t4", text: "Turn 4 — the one worth quoting." }, { role: MessageRole.user, id: "t5", text: "Turn 5." }, { role: MessageRole.agent, id: "t6", text: "Turn 6." }, { role: MessageRole.user, id: "t7", text: "Turn 7." }, { role: MessageRole.agent, id: "t8", text: "Turn 8." }, { role: MessageRole.user, id: "t9", text: "Turn 9." }, { role: MessageRole.agent, id: "t10", text: "Turn 10 — the live edge." }, ];
VerticalLayout { padding: 20px; spacing: 12px;
Rectangle { // A fixed viewport: the transcript's height must not depend on // the line under it that reports what the transcript is doing. height: 240px; border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; clip: true;
transcript := MessageScroller { messages: root.turns; } }
HorizontalLayout { spacing: 8px; alignment: start;
Button { variant: ButtonVariant.outline; text: "To the start"; clicked => { transcript.scroll-to-start(); } } Button { variant: ButtonVariant.outline; text: "To the quoted turn"; // The consumer maps its own id to a position; it built the array. clicked => { transcript.scroll-to-turn(3); } } Button { variant: ButtonVariant.outline; text: "To the latest"; clicked => { transcript.jump-to-latest(); } } }
Text { text: "Anchor: " + transcript.anchor-id + " · turns " + transcript.first-visible-turn + "–" + transcript.last-visible-turn + " in view"; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}The band of visible turns, and the anchor
first-visible-turn and last-visible-turn are the turns the reader has in
front of them, as positions in messages; anchor-id is the one at the top
edge, named rather than numbered so it stays the same turn while history lands
above it. Scroll the transcript below and watch all three move.
A turn marked anchor: true is not merely scrolled into sight when it
arrives: it goes to the top of the viewport under anchor-peek, so the reader
starts reading at the start of the turn and can still see there was something
before it. The last turn carries a viewport’s worth of headroom when it is an
anchor, because a transcript cannot be scrolled past its own end.
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";import { MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 420px; background: Tokens.color-background;
property <[MessageItem]> turns: [ { role: MessageRole.user, id: "brief", text: "Summarize the parser thread." }, { role: MessageRole.agent, id: "step-1", text: "Reading the thread." }, { role: MessageRole.agent, id: "step-2", text: "Twelve turns, three of them about spans." }, { role: MessageRole.agent, id: "summary", anchor: true, text: "Here is the summary. The span is dropped in `fold_expr`, which is why every diagnostic under a folded expression points at the file rather than at the line — and why the fix is one line plus a test that folds a nested call." }, ];
VerticalLayout { padding: 20px; spacing: 12px;
Rectangle { // A fixed viewport: the transcript's height must not depend on // the line under it that reports what the transcript is doing. height: 240px; border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; clip: true;
transcript := MessageScroller { messages: root.turns; // How much of the turn before an anchored one stays on screen // above it: landing flush against the top edge reads as // content having been cut off. anchor-peek: 48px; } }
Text { text: "Anchor: " + transcript.anchor-id + " · turns " + transcript.first-visible-turn + "–" + transcript.last-visible-turn + " in view" + (transcript.pinned ? " · pinned" : " · released"); color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}What arrives is announced
status-label is what assistive technology hears when the transcript grows.
It defaults to the newest turn’s own text, which is right for a chat and wrong
for a transcript whose turns are long or arrive in fragments — override it to
summarize, or to translate.
import { MessageItem, MessageScroller } from "@glint/components/message-scroller.slint";import { MessageRole } from "@glint/components/message-bubble.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 340px; background: Tokens.color-background;
property <[MessageItem]> turns: [ { role: MessageRole.user, id: "t1", text: "Read the crate and tell me what it does." }, { role: MessageRole.agent, id: "t2", text: "It is a themable Slint component library: 74 components, one token layer, and a docs site built from the same sources." }, ];
VerticalLayout { padding: 20px;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-lg; clip: true;
MessageScroller { messages: root.turns; spacing: 16px; content-padding: 20px; max-surface-width: 360px; jump-to-latest-label: "Jump to the newest turn"; status-label: "Ada answered."; } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
messages | in [MessageItem] | no default | The transcript, oldest turn first. The consumer owns the model; what the scroller follows is its growth. |
spacing | in length | Tokens.spacing-md | Vertical gap between two turns. |
content-padding | in length | Tokens.spacing-md | Gutter between the bubbles and the viewport edges — it also keeps them clear of the scrollbar. |
max-surface-width | in length | 480px | Widest a bubble's surface may grow, forwarded to every turn. |
jump-to-latest-label | in string | @tr("Jump to latest") | The jump-to-latest control's name — rendered on it and announced. |
status-label | in string | root.messages.length > 0 ? root.messages[root.messages.length - 1].text : "" | Announced politely whenever the transcript grows; the newest turn by default, overridable to translate or to summarize. |
anchor-peek | in length | 64px | How much of the turn before an anchored one stays on screen above it. Landing a turn flush against the top edge reads as content having been cut off; a peek says there is history up there and it is where you were. |
pinned | out bool | no default | True while the viewport follows the newest turn. The scroller owns it: it holds while the view rests at the end of the transcript and releases the moment the reader scrolls away. |
first-visible-turn | out int | no default | The band of turns the reader has in front of them, as positions in messages. A host marks turns read, fires receipts or follows the transcript in a side index off these. |
last-visible-turn | out int | no default | |
anchor-id | out string | no default | The turn the viewport treats as current — the one at its top edge. Named rather than numbered, so it stays the same turn while history lands above it. |
Functions
| Function | Description |
|---|---|
jump-to-latest() | Scroll to the newest turn and follow it again — what the jump-to-latest control does, and how a host re-anchors after replacing the model. |
scroll-to-start() | Back to the beginning of the conversation. The oldest turn sits at offset zero, so this one landing needs no estimate. |
scroll-to-turn(index: int) | Put the turn at index at the top of the viewport — the seek behind "jump to the quoted message". The consumer maps its own id to a position; see the header for why that division is the platform's. |
Accessibility
- The transcript is one region. The scroller carries the
regionrole under the translatable name “Transcript”, so a screen reader has one landmark to jump to rather than a wall of turns. - The turns are a list. Inside the region, the scrolling viewport carries
the
listrole and the transcript’s whole turn count, so assistive technology can say where in it a turn sits — including the turns that are not instantiated, which a virtualized list otherwise cannot count. - An arriving turn is announced politely. The viewport is a live region
carrying
status-label, so a reader hears the newest turn without polling the transcript and without being interrupted. - A virtualized row that is off screen is not in the tree. Only the turns in view are instantiated, so assistive technology walks what the reader has in front of them — which is why the keyboard has to be able to move the viewport.
- Keyboard. The transcript is a stop in the reading order (ADR-0032): the arrows, Page Up / Page Down and Home / End walk it. The jump-to-latest control is the next stop, so two Tabs reach it — and it is out of the tab order entirely while the viewport is pinned.
- History does not move the reader. When turns are prepended, the scroller seeks back to the turn the reader was on, at the offset into it they were already at — so loading history does not yank the transcript out from under someone reading it.