navius

Message Scroller

A managed scroll region for conversational transcripts: anchored turns, live-edge follow, and prepend-safe history.

How do I keep the reader's place while a reply streams in?
Anchor the turn near the top and let the answer grow into the screen below it.
Does new content ever jump my scroll position?
Only while you are already at the live edge. Scroll away and new content arrives offscreen instead.
What happens if I load older history above what's visible?
Prepend with stable message ids and the row you were reading stays fixed in place.

Features

  • Anchors each newly appended turn (ScrollAnchor) near the top of the viewport, keeping a peek of the previous item visible above it (ScrollPreviousItemPeek).
  • Follows the live edge while content streams in (AutoScroll), but only while the reader is already there; wheel, touch, keyboard scrolling and explicit jumps release it.
  • Preserves the currently visible row when older messages are prepended (PreserveScrollOnPrepend), keyed on stable MessageId values.
  • Opens at the end, the start, or the last anchored turn (DefaultScrollPosition).
  • A cascaded MessageScrollerContext exposes jump commands (ScrollToMessageAsync, ScrollToStartAsync, ScrollToEndAsync), the scrollable-edge state and a lazy visibility subscription, reachable from anywhere inside the Provider, including outside the scroller frame.
  • The scroll hot path runs entirely in the engine: no rerender of transcript rows on scroll or stream.

Installation

Install the brain, or copy just this primitive in with the CLI.

Anatomy

Import the parts and assemble them.

API Reference

Cascades the scroll options and the shared context to every descendant. Renders no DOM of its own.

Prop Type Default
AutoScroll bool false
DefaultScrollPosition string "end"
ScrollEdgeThreshold double 8
ScrollMargin double 0
ScrollPreviousItemPeek double 64
ChildContent RenderFragment? -

The scroller frame: the positioning parent for the scroll button and the mirror target for the scrollable-edge and autoscrolling state. Must sit in a height-constrained container. Renders a div.

Prop Type Default
ChildContent RenderFragment? -
…attributes IDictionary<string, object>? -
Data attribute Values
[data-navius-messagescroller] Present on the root
[data-scrollable] "start" | "end" | "start end" | absent, mirrored with the viewport
[data-autoscrolling] Present while the viewport is programmatically scrolling to the live edge, mirrored with the viewport

The scroll container: a labelled, keyboard-focusable scroll region (role=region, aria-label=Messages, tabindex=0, all overridable through the attribute seam). Give it a vertical overflow and a bounded size. Renders a div.

Prop Type Default
PreserveScrollOnPrepend bool true
ChildContent RenderFragment? -
…attributes IDictionary<string, object>? -
Data attribute Values
[data-navius-messagescroller-viewport] Present on the viewport
[data-scrollable] "start" | "end" | "start end" | absent, mirrored with the root
[data-autoscrolling] Present while the viewport is programmatically scrolling to the live edge, mirrored with the root

The transcript container: a live region (role=log, aria-relevant=additions) so appended rows announce without narrating every streamed token. Every direct child must be an Item. Renders a div.

Prop Type Default
SpacerClass string? -
ChildContent RenderFragment? -
…attributes IDictionary<string, object>? -
Data attribute Values
[data-navius-messagescroller-content] Present on the transcript container
[data-navius-messagescroller-spacer] Present on the internal, engine-sized spacer that makes room for anchored rows; style it via SpacerClass

One transcript row boundary: a message, a marker, a typing indicator, a separator or a load-earlier row. Renders a div.

Prop Type Default
MessageId string? -
ScrollAnchor bool false
ChildContent RenderFragment? -
…attributes IDictionary<string, object>? -
Data attribute Values
[data-navius-messagescroller-item] Present on every row
[data-message-id] Mirrors MessageId
[data-scroll-anchor] "true" | "false", mirrors ScrollAnchor

The scroll-to-start/end control. Context-aware: active only while the viewport can still scroll toward its direction; inert when it can't. Renders a button.

Prop Type Default
Direction string "end"
Behavior string "smooth"
ChildContent RenderFragment? -
…attributes IDictionary<string, object>? -
Data attribute Values
[data-navius-messagescroller-button] Present on the button
[data-direction] "start" | "end"
[data-active] "true" | "false", whether the button can currently scroll; false also sets tabindex=-1 and inert

Accessibility

The Viewport is a labelled, keyboard-focusable scroll region (role="region", aria-label="Messages", tabindex="0"). The Content is a live region (role="log", aria-relevant="additions") so appended rows announce without narrating every streamed token; pass aria-busy through the attribute seam while a turn streams if announcements should wait for the completed row. The Button is a real <button> that goes inert with tabindex="-1" when it cannot scroll in its direction, so it never becomes a ghost focus stop.

When the Viewport (or a focusable child) is focused, the standard browser scroll keys apply: Arrow keys, Page Up / Page Down, and Home / End. Navius does not intercept these; the engine only reacts to them to release the streamed-reply follow.

Key Description
Space / Enter When focus is on the Button, activates it and scrolls toward its configured direction.