Message Scroller
A conversation viewport that follows streamed output at the live edge, and releases control the moment the reader scrolls away. It's a native overflow container with role="log" andaria-live="polite" — the standard chat-transcript pattern — plus one behaviour that's tedious to get right by hand: knowing when "keep up with the stream" should stop.
Playground
Toggle every adjustable prop and watch the component — and the code — update live.
Usage
<MessageScroller
maxBlockSize="12rem"
showJumpToLatest={true}
jumpLabel="Jump to latest"
threshold={24}
onPinnedChange={(pinned) => console.log(pinned)}
>
<MessageList>
{messages.map((m) => (
<Message key={m.id} author={m.author}>
<MessageBubble>{m.text}</MessageBubble>
</Message>
))}
</MessageList>
</MessageScroller><kernel-message-scroller
max-block-size="12rem"
jump-label="Jump to latest"
threshold="24"
>
<kernel-message-list>
<kernel-message author="user"><kernel-message-bubble tone="accent">Hi</kernel-message-bubble></kernel-message>
<kernel-message author="assistant"><kernel-message-bubble>Hello</kernel-message-bubble></kernel-message>
</kernel-message-list>
</kernel-message-scroller>
<script type="module">
// Appending later is fine — new children are relocated into the viewport.
const scroller = document.querySelector("kernel-message-scroller");
scroller.addEventListener("kernel-pinned-change", (e) => console.log(e.detail.pinned));
</script>Props
| Prop | Type | Default |
|---|---|---|
maxBlockSize | string | — |
defaultPinned | boolean | true |
threshold | number | 24 |
onPinnedChange | (pinned: boolean) => void | — |
showJumpToLatest | boolean | true |
jumpLabel | ReactNode | "Jump to latest" |
conversationKey | unknown | — |
Why pinning isn't a controlled prop
Pinning isn't application state. It's an answer to "is the reader currently at the bottom?", which only the DOM knows. A controlledpinned prop would let a parent assertpinned while the reader is 400px up the transcript, and the only way to honour it would be to yank them back down mid-read. SodefaultPinned seeds it, onPinnedChangereports it, and the jump control (or scrolling back to the bottom) re-pins it.
Pin state is derived from scroll position alone — no wheel, touch, or key heuristics. Every scroll the controller performs itself is flagged, so any unflagged scroll event is by definition the reader moving, and "did they end up at the bottom?" answers both the unpin and the re-pin case with one rule. Wheel and touch listeners would re-derive the same answer less reliably, and would miss keyboard and scrollbar-drag scrolling entirely.
Using the behaviour on its own
The core is exported for surfaces that scroll but aren't transcripts — a streaming log, a terminal pane, CodeBlock's own following. useStickToBottom() returns{ viewportRef, contentRef, pinned, scrollToBottom };StickToBottomController is the framework-free class both packages share.
Accessibility
role="log"witharia-live="polite"announces appended messages without interrupting. It's deliberately verbose for very long transcripts, so both attributes are overridable — passaria-live="off"when your app announces messages itself.- The viewport is focusable. An overflow container that isn't focusable can't be scrolled by keyboard at all in Firefox or Safari, which strands keyboard-only readers in a transcript they can see and can't move.
- The jump control is hidden with
visibilityrather thandisplay, so it leaves the tab order while hidden and both its reveal and its dismissal can still transition. - Following the live edge is always instant, never smooth-scrolled: a per-chunk animation would never catch up with the next chunk. Only the explicit jump animates, and
prefers-reduced-motionmakes that instant too.