Skip to content
Glint UI

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.

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:

FieldWhat it is
roleWho authored the turn — it tints the bubble and picks its side
textWhat the turn says, and what the bubble wraps
idA stable name for the turn, reported back as anchor-id
anchorMarks 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.

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.

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.

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.

API Reference

Properties

PropertyTypeDefaultDescription
messagesin [MessageItem]no defaultThe transcript, oldest turn first. The consumer owns the model; what the scroller follows is its growth.
spacingin lengthTokens.spacing-mdVertical gap between two turns.
content-paddingin lengthTokens.spacing-mdGutter between the bubbles and the viewport edges — it also keeps them clear of the scrollbar.
max-surface-widthin length480pxWidest a bubble's surface may grow, forwarded to every turn.
jump-to-latest-labelin string@tr("Jump to latest")The jump-to-latest control's name — rendered on it and announced.
status-labelin stringroot.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-peekin length64pxHow 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.
pinnedout boolno defaultTrue 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-turnout intno defaultThe 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-turnout intno default
anchor-idout stringno defaultThe 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

FunctionDescription
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 region role 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 list role 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.