MessageScroller
The conversation scroll container: a scroll-intent model — follow new content only when the reader is at the bottom (AI style), an always-pinned IM mode, a scroll-to-bottom button, jump-free history prepending (including async-resource position preservation), turn anchoring ("return to the last question"), and first-paint jump prevention. The component owns no messages (only scroll behavior), so hosts can connect any backend.
Basic Usage
Conversation flow (default-position=end opens at the bottom)
Defaults to default-position="end": opening lands at the bottom. The viewport is role="region" with an accessible name (locale "Messages" by default, override with label) and tabindex="0" (focus with Tab, scroll with arrow keys); the content container is role="log" + aria-relevant="additions" (screen readers announce only new rows).
First-paint jump prevention (data-pending-scroll)
No top flash before the initial position is applied (end / last-anchor)
default-position="end" (and last-anchor) requires real positioning: on connect the component reflects data-pending-scroll on the host while the viewport is visibility: hidden (layout stays available for positioning measurements); the positioning and the attribute removal complete within the same frame — the reader never sees the "top flash then jump to bottom" unpositioned frame. default-position="start" is naturally positioned (scrollTop=0 is the initial value) and sets no reflection. If the reader scrolls before positioning (the yield guard), the reflection is removed as well — the viewport is never left hidden.
AI-style bottom follow (auto-scroll)
auto-scroll: follow streaming output only while at the bottom
auto-scroll is the safe AI-assistant mode: it follows streaming growth only while the reader is at the bottom (near-bottom ≤ edge-threshold, 8px default); scrolled-up reading is never hijacked — "never move the reader against their intent". Content growth is sensed via slotchange + ResizeObserver (pure text growth without slotchange still follows).
IM-style always pinned (pin-to-bottom)
pin-to-bottom: always pulled back to the bottom
pin-to-bottom switches to IM mode: new content always scrolls to the bottom, even pulling readers back. Decision priority: pin-to-bottom > turn-anchor > auto-scroll.
Jump button & state events
Jump-to-bottom button + oas-scroll-state feedback
Filler 1 — once content overflows, the jump button appears at the bottom-right when you leave the bottom.
Filler 2
Filler 3
Filler 4
Filler 5
Filler 6 (very bottom)
The built-in jump button (part="button", localized accessible name; customize via slot="button") appears when off-bottom and scrollable downward; clicking scrolls to the bottom and it disappears. Crossing a decision line dispatches oas-scroll-state (detail { atBottom, atTop, canScrollStart, canScrollEnd }) and reflects data-scrollable="start end" (for edge fades etc.). scrollToEnd()/scrollToStart() provide programmatic positioning (behavior accepts 'smooth').
Load history on scroll-up (async-resource position preservation)
Prepending older messages keeps the reading position
Late-loading images inside history grow without moving the anchor row
When older messages are prepended, preserve-scroll-on-prepend (on by default, explicit ="false" disables) compensates scrollTop by the content-coordinate displacement of the pre-insertion first node — when one frame both prepends history and appends new messages, only the top insertion settles (bottom appends never over-compensate). After compensation the component locks a preservation anchor (the pre-insertion first node): when async resources inside the history (images etc.) grow the upper content later (ResizeObserver channel, no slotchange signal), scrollTop keeps being compensated by the anchor's viewport drift, so reading never jumps while resources load. With a stable message-id on the row the anchor is re-queried by id first (host-side row rebuilds still resolve); without an id the element reference is used.
Opening position last-anchor & scrollToMessage
default-position=last-anchor: open right at the last question
Marking a row with the anchor attribute makes it a turn anchor (in AI scenarios typically the user's question row); default-position="last-anchor" opens at the top of the last anchor — long conversations resume at "the last question position" instead of dropping to the bottom or jumping to the top; when the anchor sits near the bottom (less than a viewport of content below), it displays bottom-clamped (the scroll ceiling, matching the product intuition of "the last question at the bottom of the screen"). With no anchor it falls back to end. scrollToMessage(id, opts?) scrolls precisely to the top of a message by message-id (returns true on hit; unknown ids return false without scrolling).
Turn anchoring (turn-anchor)
turn-anchor: the question stays pinned at the viewport top while the answer streams in (prev-peek context)
With turn-anchor set, one "question + answer" exchange is the anchoring unit: while following, the last anchor-marked turn anchor is pinned at prev-peek px from the viewport top (64 default, showing the tail of the previous message for context) and the answer flows in beneath it. Position is intent — anchor near the viewport top (or in the peek alignment window) means anchor-top following; the reader at the bottom keeps bottom following (new turns enter from below); away from both means the reader has taken over and nothing is pulled. The follow mode is resolved once per following episode, so an anchor crossing the top window during bottom following is never yanked to the top.
Visibility channel (track-visible)
Visible message list + current anchor
With track-visible present the visibility channel is enabled (pay-for-use, zero computation when off): it dispatches oas-visible-change (detail { visibleMessageIds, currentAnchorId }) when the visible set changes, and always exposes visibleMessageIds / currentAnchorId getters (computed on demand). visibleMessageIds lists the message ids intersecting the viewport (rows without message-id are excluded); currentAnchorId is the turn at the reader position — the last anchor-marked row near the viewport top. Useful for outline menus, search positioning, and read tracking.
Long-conversation virtualization (compose with oas-virtual-list)
The scroller owns anchoring/follow for normal conversations; for thousands of turns, hand the message data to oas-virtual-list for viewport windowing (only visible rows + buffer are rendered) while each row still renders as oas-message-row / oas-bubble — virtualization belongs to virtual-list, scroll intent to the scroller, and neither rewrites the other. Use scrollToIndex(index, { align: 'end' }) to open at the latest and follow after appending.
A 2000-message virtualized conversation (append one)
Boundaries
- Visibility channel: when
track-visibleis off, nothing is computed and nothing is dispatched (pay-for-use); when on, it computes viewport intersection (long conversations pay the per-row rect cost only when the host opts in). Rows withoutmessage-idnever enter the visible set. - Virtualization & render cost: offscreen-row render skipping (
content-visibilityetc.) arrives with virtualization (enabling it alone on slotted elements stalls at the intrinsic-size placeholder — verified and pulled); multi-thousand-turn conversations compose with a virtual list on the host side. - Empty state: renders an empty log; hosts place their own empty-state content.
- Prepend preservation boundary: the preservation anchor releases on real reader scrolling (reader position wins); with
pin-to-bottom/ bottom following the compensation is moot (pinning settles after). Same-frame mixed settlement relies on the pre-insertion first element still being in the DOM (no compensation for that frame if the host removed it). - First-paint prevention boundary:
data-pending-scrollonly covers the "connect → first-frame positioning" window; hosts fetching the first messages asynchronously after mount are handled by theauto-scroll/pin-to-bottomloading behavior. The viewport is unfocusable while pending (a millisecond-scale window). labelis absorbed into the viewport accessible name and removed from the host (global-attribute absorption convention).anchor/message-idare host-authored cooperation markers on slotted rows (read-only to the scroller); under Vue hosts both pass through as plain attributes and are never stripped.
API
oas-message-scroller
Attributes
| Attribute | Description | Type | Default |
|---|---|---|---|
auto-scroll | AI-style bottom follow (boolean presence, off by default): follows streaming growth only while the reader is at the bottom (near-bottom ≤ edge-threshold); scrolled-up reading is never interrupted | boolean | — |
default-position | Opening position: start (top) / end (bottom, default; invalid values fall back to end) / last-anchor (top of the last anchor-marked message — the AI "return to the last question" scenario; falls back to end with no anchor) | — | — |
edge-threshold | Bottom detection threshold in px (default 8) — within the threshold of the bottom counts as at-bottom (near-bottom detection) | — | — |
label | Overrides the viewport accessible name (locale "Messages" by default; absorbed into the viewport and removed from the host — global-attribute absorption convention) | — | — |
pin-to-bottom | IM-style always pinned (boolean presence): new content always scrolls to the bottom, pulling readers back; wins when combined with auto-scroll | boolean | — |
preserve-scroll-on-prepend | Compensates scrollTop when older messages are prepended to keep the reading position (on by default; explicit ="false" disables) — settles precisely by the pre-insertion first-node displacement (same-frame append+prepend mixes settle only the top insertion), then locks a preservation anchor: async resources (images etc.) growing the upper history keep being compensated by the anchor's viewport drift (message-id re-queried first) | — | — |
prev-peek | Context px revealed above the anchor top during turn-anchor alignment (default 64; invalid/negative values fall back to 64) | — | — |
track-visible | Boolean present: enables the visibility channel (oas-visible-change event + visibleMessageIds / currentAnchorId getters); zero computation when off (pay-for-use) | boolean | — |
turn-anchor | Turn anchoring (boolean presence): one question+answer exchange is the anchoring unit — while the anchor sits at the viewport top the last anchor-marked message is pinned at prev-peek from the top with answers flowing in beneath; bottom following continues at the bottom; scrolled-up readers are never pulled | boolean | — |
Events
| Event | Description |
|---|---|
oas-scroll-state | Dispatched when scrolling crosses a decision line, detail { atBottom, atTop, canScrollStart, canScrollEnd }; an initial state is broadcast once on the first frame (after initial positioning) — received even when the host attaches its listener after the upgrade |
oas-visible-change | Fired when the visible set changes while track-visible is on; detail { visibleMessageIds, currentAnchorId } (for outline / search positioning) |
Slots
| Name | Description |
|---|---|
| default | Message rows (oas-message-row / oas-marker etc.; the anchor / message-id attributes on rows are scroller cooperation markers) |
button | Custom jump-to-bottom button content (replaces the built-in arrow glyph; visibility and click remain component-owned; default is the built-in part=button) |
CSS Variables
| CSS Variable | Default |
|---|---|
--oas-message-gap | var(--oas-space-4) |
Methods
| Method | Description |
|---|---|
scrollToEnd(opts?) | Scroll to bottom; opts.behavior accepts 'smooth' (instant by default) |
scrollToStart(opts?) | Scroll to top (same options) |
scrollToMessage(id, opts?) | Scroll to the top of the message row with the given message-id; returns true on hit, false without scrolling for unknown ids (same options) |
State reflection: data-scrollable ("start" / "end" / "start end" space-separated); data-pending-scroll (first-paint jump prevention — present until the initial position is applied, removed after positioning or reader yield). ARIA: viewport role="region" + accessible name + tabindex="0"; content container role="log" + aria-relevant="additions". CSS variable: --oas-message-gap (row gap, default var(--oas-space-4)). Parts: ::part(scroller) / ::part(viewport) / ::part(content) / ::part(button).