Conversation Stream
A virtualized conversation scroller that sticks to the bottom and pages older messages in at the top.
Conversation Stream
Basic Usage
Conversation Stream
Loading demo...
Interaction Contract
- The component is generic over
T, so the element type ofitemsflows through to theitemslot's scope with no casting on your side. itemKeyis required. Virtualization caches positions by key, and an array index shifts every position when a message is prepended.estimatedItemHeightseeds the first layout only; real heights are measured and corrected afterwards.- Sticking to the bottom is conditional. New content follows while the user is at the bottom; once they scroll up it stops yanking them back and shows a scroll-to-bottom affordance instead.
streamingonly drives that affordance's "new content" state — it does not decide whether the view sticks.loadOlderis called near the top, and the contract is that the consumer prepends intoitemsand then resolves{ hasMore }. The component never owns the array.- The viewport anchor is preserved across a prepend, so users are not thrown to a different position.
hasMoreInitialdefaults to an explicitundefinedrather thanfalse. That opts out of Vue's absent-Boolean-prop coercion so "not asked yet" stays distinguishable from "there is no history". Passfalseexplicitly when you know there is none.- A throwing
loadOlderemitsload-errorand renders thetop-errorslot, whose scope carriesretry.
API
Props
| Name | Type | Default | Description |
|---|---|---|---|
items | T[] | — | The messages. Required. |
itemKey | ConversationStreamItemKey<T> | — | Function returning a stable key. Required. |
estimatedItemHeight | number | 96 | Height assumed for unmeasured items. |
overscan | number | 4 | Extra items rendered on each side of the viewport. |
loadOlder | () => Promise<ConversationStreamLoadResult> | — | Called near the top; prepend into items, then resolve { hasMore }. |
hasMoreInitial | boolean | undefined | Whether history may exist before the first loadOlder answers. |
streaming | boolean | false | Drives the scroll-to-bottom affordance's new-content state. |
Events
| Name | Payload | Description |
|---|---|---|
at-bottom-change | (atBottom: boolean) | Emitted when the at-bottom state changes. |
load-error | (error: unknown) | Emitted when loadOlder throws. |
Exposed
| Name | Type | Description |
|---|---|---|
scrollToBottom | () => void | Scrolls to the bottom. |
scrollToIndex | (index: number) => void | Scrolls to an index. |
atBottom | ComputedRef<boolean> | Whether the view is at the bottom. Read-only. |
Slots
| Name | Scope | Description |
|---|---|---|
item | { item: T, index: number } | Renders one message. |
empty | — | Shown when items is empty. |
top-loading | — | Shown while older messages load. |
top-error | { retry: () => void } | Shown when loading failed, with a retry callback. |
top-done | — | Shown when there is no more history. |
scroll-to-bottom | { streaming: boolean } | Replaces the scroll-to-bottom affordance. |
Best Practices
- Key by the message's own id, never by index. This is a correctness requirement for virtualization, not an optimization.
- Prepend inside
loadOlder. Resolving without touchingitemsleaves the component believing history is still pending. - Pass
hasMoreInitial: falseexplicitly when you know there is no history — the defaultundefinedmeans "unknown", not "none". - Keep
estimatedItemHeightclose to the real average; a large mismatch makes the scrollbar jump noticeably on first paint. - Use the exposed
scrollToBottom/scrollToIndexfor programmatic movement instead of driving the scroll container yourself, which fights the stick-to-bottom logic.