# @humanspeak/svelte-virtual-chat — full reference > Concatenated dump of every doc page under https://virtualchat.svelte.page/docs. > Each section is bounded by an HTML comment with the source URL, > so agents can extract individual pages or cite a specific section. --- # SvelteVirtualChat ## Overview SvelteVirtualChat is a Svelte 5 component that virtualizes chat message rendering. Only visible messages exist in the DOM. The component handles follow-bottom behavior, LLM token streaming stability, and history prepend with scroll anchor preservation. ## Key Features - Bottom gravity: messages sit at the bottom of the viewport - Follow-bottom: viewport stays pinned to newest message - Scroll-away: new messages do not snap back when scrolled up - Virtualized: about 20 DOM nodes regardless of message count - Streaming-native: height changes batched per animation frame - History prepend: load older messages without viewport jump - Message-aware: uses IDs, not array indices - TypeScript with generics - Svelte 5 runes and snippets ## Installation ```sh pnpm add @humanspeak/svelte-virtual-chat ``` ## Basic Usage ```svelte msg.id} estimatedMessageHeight={72} containerClass="h-[600px]" viewportClass="h-full" > {#snippet renderMessage(message, index)}
{message.content}
{/snippet}
``` ## Props - messages: TMessage[] - chronological message array - getMessageId: (msg) => string - unique ID extractor - renderMessage: Snippet - message renderer - estimatedMessageHeight: number, default 72 - followBottomThresholdPx: number, default 48 - overscan: number, default 6 - onNeedHistory: () => void - called near top for history loading - onFollowBottomChange: (following) => void - onDebugInfo: (info) => void - live virtualization stats - containerClass, viewportClass: string - testId: string ## Imperative API - scrollToBottom({ smooth?: boolean }) - scrollToMessage(id, { smooth?: boolean }) - isAtBottom(): boolean - getDebugInfo(): SvelteVirtualChatDebugInfo ## Companion Libraries - @humanspeak/svelte-markdown - Markdown renderer with LLM streaming mode - @humanspeak/svelte-virtual-list - General-purpose virtual list ## Package Links - npm: [@humanspeak/svelte-virtual-chat](https://www.npmjs.com/package/@humanspeak/svelte-virtual-chat) - GitHub: [humanspeak/svelte-virtual-chat](https://github.com/humanspeak/svelte-virtual-chat) - Docs: [virtualchat.svelte.page](https://virtualchat.svelte.page) --- # Imperative API > Imperative methods exposed by SvelteVirtualChat via bind:this — scrollToBottom, scrollToMessage, isAtBottom, and getDebugInfo for Svelte 5 chat UIs. **Source:** [https://virtualchat.svelte.page/docs/api/imperative](https://virtualchat.svelte.page/docs/api/imperative) --- Bind the component instance to access these methods: ```svelte ``` ## `scrollToBottom(options?)` Scrolls the viewport to the bottom. ```typescript chat.scrollToBottom() // instant chat.scrollToBottom({ smooth: true }) // smooth animation ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `smooth` | `boolean` | `false` | Use smooth scrolling animation | ## `scrollToMessage(id, options?)` Scrolls to a specific message by its ID. ```typescript chat.scrollToMessage('msg-42') chat.scrollToMessage('msg-42', { smooth: true }) ``` | Param | Type | Description | |-------|------|-------------| | `id` | `string` | The message ID (as returned by `getMessageId`) | | `smooth` | `boolean` | Use smooth scrolling animation | If the message ID is not found, this is a no-op. ## `isAtBottom()` Returns whether the viewport is currently in follow-bottom state. ```typescript if (chat.isAtBottom()) { // Safe to assume user sees the latest message } ``` ## `getDebugInfo()` Returns a snapshot of the current internal state. ```typescript const info = chat.getDebugInfo() console.log(info.totalMessages) // 1000 console.log(info.renderedCount) // 18 (DOM nodes) console.log(info.isFollowingBottom) // true ``` ### `SvelteVirtualChatDebugInfo` | Field | Type | Description | |-------|------|-------------| | `totalMessages` | `number` | Total messages in the array | | `renderedCount` | `number` | Messages currently in the DOM | | `measuredCount` | `number` | Messages with measured heights | | `startIndex` | `number` | First rendered index | | `endIndex` | `number` | Last rendered index | | `totalHeight` | `number` | Calculated total content height (px) | | `scrollTop` | `number` | Current scroll position (px) | | `viewportHeight` | `number` | Viewport height (px) | | `isFollowingBottom` | `boolean` | Whether viewport is pinned to bottom | | `averageHeight` | `number` | Average measured message height (px) | | `heightCacheVersion` | `number` | Monotonic counter of height-cache mutations (each measurement, removal, or clear adds 1) | # Props Reference > Complete props reference for SvelteVirtualChat — configure messages, scroll thresholds, custom snippets, CSS classes, and event handlers for Svelte 5. **Source:** [https://virtualchat.svelte.page/docs/api/props](https://virtualchat.svelte.page/docs/api/props) --- ## Required Props ### `messages` - **Type:** `TMessage[]` - **Required** Array of messages in chronological order (oldest first). The component is reactive — push, splice, or reassign to update. ### `getMessageId` - **Type:** `(message: TMessage) => string` - **Required** Extracts a unique, stable identifier from a message. Used for height caching, keyed rendering, and `scrollToMessage`. ### `renderMessage` - **Type:** `Snippet<[message: TMessage, index: number]>` - **Required** Svelte 5 snippet that renders a single message. Receives the message object and its index in the array. ```svelte {#snippet renderMessage(message, index)}
{message.role}

{message.content}

{/snippet} ``` ## Snippet Props ### `header` - **Type:** `Snippet` Optional snippet rendered at the top of the scrollable content, above all messages. Always in the DOM (never virtualized). Scrolls with content — not sticky or fixed. Use for "load more" buttons, date separators, or channel banners. ```svelte {#snippet header()}
Beginning of conversation
{/snippet} ``` ### `footer` - **Type:** `Snippet` Optional snippet rendered at the bottom of the scrollable content, below all messages. Always in the DOM (never virtualized). Height changes automatically trigger follow-bottom snapping — perfect for typing indicators. ```svelte {#snippet footer()} {#if isTyping}
Assistant is typing...
{/if} {/snippet} ``` ## Optional Props ### `estimatedMessageHeight` - **Type:** `number` - **Default:** `72` Height estimate in pixels for messages not yet measured by ResizeObserver. Affects initial layout before real heights are known. Closer estimates = less layout shift. ### `followBottomThresholdPx` - **Type:** `number` - **Default:** `48` Distance in pixels from the bottom at which the viewport is considered "at bottom" for follow-bottom behavior. Increase this if follow-bottom is too sensitive. ### `overscan` - **Type:** `number` - **Default:** `6` Number of extra messages to render above and below the visible viewport. Higher values = smoother scrolling but more DOM nodes. ### `onNeedHistory` - **Type:** `() => void | Promise` Called when the user scrolls near the top of the chat. Use this to load older messages. The component calls this once when `scrollTop` is within half a viewport height of the top. ### `onFollowBottomChange` - **Type:** `(isFollowing: boolean) => void` Called when the follow-bottom state transitions. Use this to show a "new messages" indicator when the user scrolls away. ### `onDebugInfo` - **Type:** `(info: SvelteVirtualChatDebugInfo) => void` Called on every scroll and render update with live stats. Useful for development overlays and performance monitoring. ### `containerClass` - **Type:** `string` - **Default:** `''` CSS class for the outermost container element. Typically set to `"h-full"`. ### `viewportClass` - **Type:** `string` - **Default:** `''` CSS class for the scrollable viewport element. Typically set to `"h-full"`. ### `viewportLabel` - **Type:** `string` - **Default:** `'Chat messages'` Accessible label for the scrollable viewport region. Set this when a page has more than one chat or needs a more specific region name. ### `testId` - **Type:** `string` Base test ID for `data-testid` attributes. When set, each internal element gets a testid (e.g., `chat-viewport`, `chat-container`, `chat-item-0`). # SvelteVirtualChat Component > Full API reference for the SvelteVirtualChat component — props, methods, callbacks, and debug info for building virtual chat UIs in Svelte 5. **Source:** [https://virtualchat.svelte.page/docs/api/svelte-virtual-chat](https://virtualchat.svelte.page/docs/api/svelte-virtual-chat) --- The `SvelteVirtualChat` component is a message-aware virtual viewport for chat UIs. It uses normal top-to-bottom chronological order internally — no inverted geometry. ```svelte msg.id} containerClass="h-full" viewportClass="h-full" > {#snippet renderMessage(message, index)}
{message.content}
{/snippet}
``` ## How It Works 1. **Height caching** — each message's height is measured via ResizeObserver and cached by ID 2. **Visible range** — on every scroll, the component calculates which messages fall within the viewport plus overscan 3. **Absolute positioning** — only visible messages are rendered, positioned via `transform: translateY()` 4. **Follow-bottom** — when at bottom, new messages and height changes trigger an automatic snap 5. **Bottom gravity** — when messages don't fill the viewport, they sit at the bottom (like a real chat) ## Generic Type Parameter The component is generic over your message type. TypeScript infers `TMessage` from the `messages` prop automatically: ```svelte msg.id} ... /> ``` No explicit generic annotation is needed — the type flows from your `messages` array. ## Related - [Props Reference](/docs/api/props) — complete props table - [Imperative API](/docs/api/imperative) — exported methods - [Getting Started](/docs/getting-started) — installation and setup # Getting Started > Install and configure @humanspeak/svelte-virtual-chat — a high-performance virtual chat viewport for Svelte 5 with follow-bottom and streaming support. **Source:** [https://virtualchat.svelte.page/docs/getting-started](https://virtualchat.svelte.page/docs/getting-started) --- **@humanspeak/svelte-virtual-chat** is a high-performance virtual chat viewport for Svelte 5. It renders only visible messages to the DOM while handling follow-bottom behavior, LLM streaming stability, and history prepend with scroll anchor preservation. ## Installation ```bash pnpm add @humanspeak/svelte-virtual-chat ``` ```bash npm install @humanspeak/svelte-virtual-chat ``` ## Quick Start Import the component and pass your messages array with a `getMessageId` function and a `renderMessage` snippet: ```svelte
msg.id} estimatedMessageHeight={72} containerClass="h-full" viewportClass="h-full" > {#snippet renderMessage(message, index)}
{message.role}

{message.content}

{/snippet}
``` ## Height Constraint The component **must** have a defined height. The `containerClass` and `viewportClass` props control the outer and inner elements. The parent element needs a fixed or flex-derived height: ```svelte
...
``` Without a height constraint, the viewport expands to fit all content and virtualization won't activate. ## What's Next - [Props Reference](/docs/api/props) — all available props and their defaults - [Imperative API](/docs/api/imperative) — scrollToBottom, scrollToMessage, getDebugInfo - [LLM Streaming Guide](/docs/guides/llm-streaming) — pair with @humanspeak/svelte-markdown - [Interactive Examples](/examples) — try it live # Accessibility > Keyboard navigation, screen reader support, and reduced-motion behavior in SvelteVirtualChat — plus guidance for building accessible chat UIs on top of it. **Source:** [https://virtualchat.svelte.page/docs/guides/accessibility](https://virtualchat.svelte.page/docs/guides/accessibility) --- SvelteVirtualChat ships with keyboard navigation, a labeled focusable scroll region, and reduced-motion support out of the box. This guide covers what the component provides and what your app should add on top. ## Focusable, labeled scroll region The scrollable viewport is a landmark that keyboard and screen reader users can reach directly: - `role="region"` with an accessible name from the `viewportLabel` prop (default: `"Chat messages"`) - `tabindex="0"` so it participates in the tab order ```svelte m.id} viewportLabel="Support conversation"> ``` Set `viewportLabel` to something meaningful for your context — "Chat messages" is a reasonable default, but "Conversation with Alex" tells a screen reader user which conversation they just landed in. ## Keyboard navigation When the viewport itself is focused, the standard scrolling keys work — and they participate in the same follow-bottom logic as wheel and touch input: | Key | Action | | ---------------------- | ------------------------------------------------- | | `ArrowUp` / `ArrowDown`| Scroll by 40px | | `PageUp` / `PageDown` | Scroll by 85% of the viewport height | | `Space` / `Shift+Space`| Page down / page up | | `Home` | Jump to the oldest message (disengages follow) | | `End` | Jump to the newest message and re-engage follow | Follow-bottom semantics are input-agnostic: - Scrolling **up** past `followBottomThresholdPx` disengages follow — new messages stop pulling the view down, exactly as with a wheel or trackpad. - Scrolling **down** while at the bottom never disengages follow, so holding `ArrowDown` during a stream is safe. - `End` is the keyboard equivalent of a "jump to newest" button. ### Interactive message content keeps its keys The component only intercepts keys when the viewport element itself has focus. Inputs, buttons, links, and any other interactive elements rendered inside messages receive their keystrokes untouched — `Space` in a textarea types a space; it does not page the chat. ### Focus and virtualization Because messages are virtualized, off-screen messages are removed from the DOM entirely. If the user has focused a control inside a message — a button, a link, an input — and then scrolls that message out of the rendered range, the browser drops focus to `document.body`. When the message scrolls back in, it re-mounts as a fresh element and focus is **not** restored to it. This is deliberate: the component does not manage focus inside message content. Restoring focus on re-entry would steal it from wherever the user has since moved — the composer, a toolbar, another message — which is more disruptive than the drop itself. Design around it rather than fighting it: - Keep primary interactive flows — the composer, action bars, "jump to newest" — **outside** the scroll viewport, where they never virtualize. - For in-message controls that start a flow (a "retry" or "copy" button on a message), complete that flow in UI that lives outside the virtualized region so focus has a stable home. - If your app genuinely needs focus restoration for in-message controls, track the focused message id yourself and re-focus the control when its message re-mounts. ## Reduced motion When the user has `prefers-reduced-motion: reduce` set, the smooth-scroll easing for new messages is disabled and the viewport lands instantly. No configuration needed. ## Programmatic scrolling is respected Assistive tooling and app code that scrolls programmatically (for example `scrollTo` from a "jump to message" feature) is treated as user navigation: follow-bottom disengages instead of yanking the view back to the bottom — even while messages are streaming in. ## What your app should add The component deliberately stays out of two areas that need app-level context: ### Announce new messages with a live region Messages are **virtualized** — only the visible slice exists in the DOM — so screen readers cannot discover off-screen messages on their own, and an `aria-live` attribute on message content would only fire for rendered nodes. Announce new messages yourself with a visually hidden polite live region, summarizing rather than duplicating: ```svelte
{announcement}
``` Throttle announcements during token streaming — announce when a message completes, not on every token. ### Pair follow state with a visible control `onFollowBottomChange` tells you when the user has scrolled away. Use it to show a focusable "jump to newest" button so the affordance exists for pointer, keyboard, and screen reader users alike: ```svelte m.id} onFollowBottomChange={(following) => (showJumpButton = !following)} > {#if showJumpButton} {/if} ``` `End` already does this from the keyboard when the viewport is focused; the button covers everyone else. # History Loading > Learn how to load older chat messages with scroll anchor preservation in SvelteVirtualChat — cursor pagination, onNeedHistory callbacks, and no-jump prepend. **Source:** [https://virtualchat.svelte.page/docs/guides/history-loading](https://virtualchat.svelte.page/docs/guides/history-loading) --- Use the `onNeedHistory` callback to load older messages when the user scrolls near the top. Prepend them to the beginning of the array — the component handles the rest. ## Basic Pattern ```svelte msg.id} onNeedHistory={loadHistory} containerClass="h-[600px]" viewportClass="h-full" > {#snippet renderMessage(message, index)}
{message.content}
{/snippet}
``` ## When Does `onNeedHistory` Fire? The callback fires when `scrollTop` is within half a viewport height of the top. It fires during scroll events, so guard against concurrent calls with an `isLoading` flag. ## Scroll Anchor Preservation When you prepend messages (`messages = [...older, ...messages]`), the component recalculates offsets. Because the visible range shifts, the currently visible messages stay in approximately the same visual position. For more precise anchor preservation, you can use the exported utilities: ```typescript import { captureScrollAnchor, restoreScrollAnchor, ChatHeightCache } from '@humanspeak/svelte-virtual-chat' ``` ## Tips - **Guard concurrency** — always check `isLoading` before fetching - **Track `hasMore`** — stop calling the API when history is exhausted - **Batch size** — load 20-50 messages per request for a good balance - **Loading indicator** — show a spinner above the messages while loading # LLM Streaming > Pair SvelteVirtualChat with LLM token streaming and markdown rendering — stable scroll, ResizeObserver-based height tracking, and jitter-free AI responses. **Source:** [https://virtualchat.svelte.page/docs/guides/llm-streaming](https://virtualchat.svelte.page/docs/guides/llm-streaming) --- SvelteVirtualChat handles streaming natively. As a message grows token by token, ResizeObserver detects the height change and the viewport stays pinned to bottom. No special handling is needed from your code — just mutate the message content. ## How It Works 1. Create an empty assistant message with `isStreaming: true` 2. Append tokens to `message.content` as they arrive 3. The component's ResizeObserver detects the height change 4. If following bottom, the viewport snaps to the new bottom (batched per animation frame) 5. When done, set `isStreaming = false` ## With @humanspeak/svelte-markdown Pair with [@humanspeak/svelte-markdown](https://www.npmjs.com/package/@humanspeak/svelte-markdown) for rich markdown rendering during streaming: ```svelte msg.id} containerClass="h-[600px]" viewportClass="h-full" > {#snippet renderMessage(message, index)}
{#if message.role === 'assistant'} {:else}

{message.content}

{/if}
{/snippet}
``` ## API Route Pattern Server-side streaming with the Anthropic SDK: ```typescript // src/routes/api/chat/+server.ts import Anthropic from '@anthropic-ai/sdk' const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY }) export async function POST({ request }) { const { messages } = await request.json() const stream = await client.messages.stream({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, messages }) const encoder = new TextEncoder() const readable = new ReadableStream({ async start(controller) { for await (const event of stream) { if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { controller.enqueue(encoder.encode(event.delta.text)) } } controller.close() } }) return new Response(readable, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) } ``` ## Client-Side Consumption ```typescript async function streamResponse(messageId: string) { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [...] }) }) const reader = response.body!.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break // stream: true keeps partial multi-byte chars buffered until complete const chunk = decoder.decode(value, { stream: true }) const msg = messages.find((m) => m.id === messageId) if (msg) msg.content += chunk } // Flush any remaining buffered bytes const final = decoder.decode() if (final) { const msg = messages.find((m) => m.id === messageId) if (msg) msg.content += final } } ``` ## Key Behavior - Height changes are batched per `requestAnimationFrame` — not per token - When following bottom, the viewport re-snaps after each batch - When scrolled away, height changes do not affect scroll position - No debouncing or throttling needed from your code # Scroll Behavior > Understand follow-bottom, scroll-away detection, and return-to-bottom behavior in SvelteVirtualChat — configure thresholds and new-message indicators. **Source:** [https://virtualchat.svelte.page/docs/guides/scroll-behavior](https://virtualchat.svelte.page/docs/guides/scroll-behavior) --- SvelteVirtualChat has two scroll states: **following bottom** and **scrolled away**. The component transitions between them automatically based on the user's scroll position. ## Follow-Bottom When the viewport is within `followBottomThresholdPx` (default 48px) of the bottom: - New messages automatically scroll into view - Height changes from streaming snap the viewport to bottom - `isFollowingBottom` is `true` ## Scrolled Away When the user scrolls up past the threshold: - New messages are added to the array but the viewport **does not move** - Streaming height changes **do not affect** the scroll position - `isFollowingBottom` is `false` ## Detecting State Changes Use `onFollowBottomChange` to react to transitions: ```svelte { isFollowing = following if (following) unreadCount = 0 }} ... /> {#if !isFollowing && unreadCount > 0} {/if} ``` ## Tuning the Threshold The `followBottomThresholdPx` prop controls the sensitivity: ```svelte ``` ## Bottom Gravity When messages don't fill the viewport (e.g., a new conversation with 2 messages), they sit at the **bottom** of the viewport — not the top. This matches the behavior of every major chat application. As more messages are added and exceed the viewport height, normal scrolling takes over. ## Return to Bottom Use the imperative API to bring the user back: ```typescript // Instant jump chat.scrollToBottom() // Smooth animation chat.scrollToBottom({ smooth: true }) ``` After returning, `isFollowingBottom` becomes `true` again and new messages will auto-scroll.