Skip to content

humanspeak/svelte-virtual-chat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

112 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

@humanspeak/svelte-virtual-chat

A high-performance virtual chat viewport for Svelte 5. Purpose-built for LLM conversations, support chat, and any message-based UI. Renders only visible messages to the DOM while maintaining smooth follow-bottom behavior, streaming stability, and history prepend with anchor preservation.

NPM version Build Status AI tokens used building this repo β€” TokenMaxing License Downloads Install size Code Style: Trunk TypeScript Types Maintenance

Why This Exists

Chat UIs are not generic lists. They have specific behaviors that general-purpose virtual list components handle poorly:

  • Messages anchor to the bottom, not the top
  • New messages should auto-scroll when you're at the bottom
  • Scrolling away should not snap you back when new messages arrive
  • LLM token streaming causes messages to grow in height mid-render
  • Loading older history should preserve your scroll position

@humanspeak/svelte-virtual-chat is opinionated about these behaviors so you don't have to fight a generic abstraction.

Features

  • Bottom gravity β€” messages sit at the bottom of the viewport, like every chat app
  • Follow-bottom β€” viewport stays pinned to the newest message while at bottom
  • Scroll-away stability β€” new messages don't yank you back when you've scrolled up
  • Virtualized rendering β€” only visible messages exist in the DOM (handles 10,000+ messages)
  • Streaming-native β€” LLM token growth is corrected in the same frame it happens, before paint
  • Header & footer β€” optional snippets for persistent content above/below messages (typing indicators, banners)
  • History prepend β€” load older messages at the top without viewport jumping
  • Keyboard accessible β€” focusable, labeled scroll region with full keyboard navigation (arrows, paging, Home/End)
  • Reduced-motion aware β€” smooth scrolling respects prefers-reduced-motion
  • Message-aware β€” uses message IDs for identity, not array indices
  • Full TypeScript β€” strict types, generics, and exported type definitions
  • Svelte 5 runes β€” built with $state, $derived, $effect, and snippets
  • Debug info β€” real-time stats via onDebugInfo callback (total, DOM count, measured, range, following state)
  • Lean by design β€” one micro-dependency (esm-env, used by the compiled output for SSR detection)

Requirements

  • Svelte 5
  • Node.js 18+

Installation

# Using pnpm (recommended)
pnpm add @humanspeak/svelte-virtual-chat

# Using npm
npm install @humanspeak/svelte-virtual-chat

# Using yarn
yarn add @humanspeak/svelte-virtual-chat

Documentation & Live Examples

Full documentation lives at virtualchat.svelte.page, including guides for LLM streaming, history loading, scroll behavior, and accessibility.

Every example below is also a live, interactive demo:

Demo What it shows
Basic Chat Follow-bottom, scroll-away, and message virtualization
LLM Streaming Token-by-token growth with a pinned viewport
History Loading Prepending older messages with scroll preservation
Header & Footer Persistent content and typing indicators

Basic Usage

<script lang="ts">
    import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'

    type Message = { id: string; role: string; content: string }

    let messages: Message[] = $state([
        { id: '1', role: 'assistant', content: 'Hello! How can I help?' },
        { id: '2', role: 'user', content: 'Tell me about Svelte.' }
    ])
</script>

<div class="h-[600px]">
    <SvelteVirtualChat
        {messages}
        getMessageId={(msg) => msg.id}
        estimatedMessageHeight={72}
        containerClass="h-full"
        viewportClass="h-full"
    >
        {#snippet renderMessage(message, index)}
            <div class="p-4 border-b">
                <strong>{message.role}</strong>
                <p>{message.content}</p>
            </div>
        {/snippet}
    </SvelteVirtualChat>
</div>

Props

Prop Type Default Description
messages TMessage[] Required Array of messages in chronological order (oldest first)
getMessageId (msg: TMessage) => string Required Extract a unique, stable ID from a message
renderMessage Snippet<[TMessage, number]> Required Snippet that renders a single message
header Snippet - Persistent content above all messages (always in DOM)
footer Snippet - Persistent content below all messages (always in DOM)
estimatedMessageHeight number 72 Height estimate in pixels for unmeasured messages
followBottomThresholdPx number 48 Distance from bottom to consider "at bottom"
overscan number 6 Extra messages rendered above/below the viewport
onNeedHistory () => void | Promise<void> - Called when user scrolls near top (load older messages)
onFollowBottomChange (isFollowing: boolean) => void - Called when follow-bottom state changes
onDebugInfo (info: SvelteVirtualChatDebugInfo) => void - Called with live stats on every scroll/render update
containerClass string '' CSS class for the outermost container
viewportClass string '' CSS class for the scrollable viewport
viewportLabel string 'Chat messages' Accessible label for the scrollable viewport region
testId string - Base test ID for data-testid attributes

Imperative API

Bind the component to access these methods:

<script lang="ts">
    let chat: ReturnType<typeof SvelteVirtualChat>
</script>

<SvelteVirtualChat bind:this={chat} ... />

<button onclick={() => chat.scrollToBottom({ smooth: true })}> Scroll to bottom </button>
Method Signature Description
scrollToBottom (options?: { smooth?: boolean }) => void Scroll the viewport to the bottom
scrollToMessage (id: string, options?: { smooth?: boolean }) => void Scroll to a specific message by its ID
isAtBottom () => boolean Check if the viewport is currently following bottom
getDebugInfo () => SvelteVirtualChatDebugInfo Get a snapshot of current debug stats

LLM Streaming

The component handles streaming natively. As a message grows token by token, ResizeObserver detects the height change and the viewport stays pinned to bottom without jitter.

Pair with @humanspeak/svelte-markdown for rich markdown rendering with streaming support:

<script lang="ts">
    import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
    import SvelteMarkdown from '@humanspeak/svelte-markdown'

    type Message = {
        id: string
        role: 'user' | 'assistant'
        content: string
        isStreaming?: boolean
    }

    let messages: Message[] = $state([])
</script>

<SvelteVirtualChat
    {messages}
    getMessageId={(msg) => msg.id}
    containerClass="h-[600px]"
    viewportClass="h-full"
>
    {#snippet renderMessage(message, index)}
        <div class="p-4 border-b">
            {#if message.role === 'assistant'}
                <SvelteMarkdown source={message.content} streaming={message.isStreaming ?? false} />
            {:else}
                <p>{message.content}</p>
            {/if}
        </div>
    {/snippet}
</SvelteVirtualChat>

History Loading

Load older messages when the user scrolls near the top. The component preserves the user's scroll position during prepend operations.

<script lang="ts">
    import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'

    let messages = $state([...recentMessages])
    let isLoading = false

    async function loadHistory() {
        if (isLoading) return
        isLoading = true
        const older = await fetchOlderMessages()
        messages = [...older, ...messages]
        isLoading = false
    }
</script>

<SvelteVirtualChat
    {messages}
    getMessageId={(msg) => msg.id}
    onNeedHistory={loadHistory}
    containerClass="h-[600px]"
    viewportClass="h-full"
>
    {#snippet renderMessage(message, index)}
        <div class="p-4">{message.content}</div>
    {/snippet}
</SvelteVirtualChat>

Header & Footer

Add persistent content above and below messages without injecting fake entries into the messages array. Both snippets are always in the DOM (never virtualized) and scroll with the content.

Footer height changes automatically trigger follow-bottom snapping β€” perfect for typing indicators:

<SvelteVirtualChat
    {messages}
    getMessageId={(msg) => msg.id}
    containerClass="h-[600px]"
    viewportClass="h-full"
>
    {#snippet header()}
        <div class="p-3 text-center text-sm text-gray-500">Beginning of conversation</div>
    {/snippet}
    {#snippet renderMessage(message, index)}
        <div class="p-4 border-b">
            <p>{message.content}</p>
        </div>
    {/snippet}
    {#snippet footer()}
        {#if isTyping}
            <div class="p-3 text-sm text-gray-500">Assistant is typing...</div>
        {/if}
    {/snippet}
</SvelteVirtualChat>

Accessibility

The scrollable viewport is a keyboard-reachable landmark out of the box:

  • Labeled region β€” role="region" + tabindex="0", named via the viewportLabel prop (default "Chat messages")
  • Keyboard navigation β€” ArrowUp/ArrowDown (40px), PageUp/PageDown/Space/Shift+Space (85% of viewport), Home (oldest message, disengages follow), End (newest message, re-engages follow)
  • Input-agnostic follow behavior β€” keyboard scrolling participates in the same follow-bottom logic as wheel and touch; holding ArrowDown during a stream never breaks follow
  • Interactive content untouched β€” keys are only intercepted when the viewport itself is focused; inputs and buttons inside messages keep their native behavior
  • Reduced motion β€” smooth scrolling is disabled automatically under prefers-reduced-motion: reduce
  • Programmatic scrolling respected β€” scrollTo from assistive tooling or app code disengages follow instead of being yanked back to the bottom

Because messages are virtualized (off-screen messages don't exist in the DOM), announce new messages yourself with a polite aria-live region, and pair onFollowBottomChange with a visible "jump to newest" button. See the Accessibility guide for patterns.

Debug Info

The onDebugInfo callback provides real-time visibility into the component's internal state:

<script lang="ts">
    import SvelteVirtualChat from '@humanspeak/svelte-virtual-chat'
    import type { SvelteVirtualChatDebugInfo } from '@humanspeak/svelte-virtual-chat'

    let stats: SvelteVirtualChatDebugInfo | null = $state(null)
</script>

<SvelteVirtualChat
    {messages}
    getMessageId={(msg) => msg.id}
    onDebugInfo={(info) => (stats = info)}
    ...
/>

{#if stats}
    <div>
        Total: {stats.totalMessages} | In DOM: {stats.renderedCount} | Following: {stats.isFollowingBottom}
    </div>
{/if}
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 the 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)

TypeScript

Full type exports for building typed wrappers and extensions:

import type {
    SvelteVirtualChatProps,
    SvelteVirtualChatDebugInfo,
    ScrollToBottomOptions,
    ScrollToMessageOptions,
    VisibleRange,
    ScrollAnchor
} from '@humanspeak/svelte-virtual-chat'

// Utility exports
import {
    ChatHeightCache,
    captureScrollAnchor,
    restoreScrollAnchor
} from '@humanspeak/svelte-virtual-chat'

How Virtualization Works

The component uses standard top-to-bottom geometry (no inverted lists):

  1. Height caching β€” Each rendered message's height is derived from real layout offsets (so bubble margins are counted, whatever your CSS does) and cached by ID; ResizeObservers trigger re-measurement when content changes
  2. Visible range β€” On every scroll, the component calculates which messages fall within scrollTop to scrollTop + viewportHeight, plus an overscan buffer
  3. Absolute positioning β€” Only visible messages are rendered, positioned via transform: translateY() inside a content div sized to the total calculated height
  4. Follow-bottom β€” While at the bottom, streaming growth is corrected in the same frame it happens (before paint, so nothing visibly shifts), and newly arriving messages ease smoothly into view
  5. Bottom gravity β€” When messages don't fill the viewport, flex-direction: column; justify-content: flex-end pushes them to the bottom

With 10,000 messages, the DOM contains ~15-25 elements instead of 10,000.

Performance

Metric Value
DOM nodes with 1,000 messages ~15-25 (viewport + overscan)
DOM nodes with 10,000 messages ~15-25 (same)
Follow-bottom correction Same frame as the growth, before paint
Height measurement Layout offsets, ResizeObserver-triggered
Streaming height updates Coalesced β€” one reactive cascade per frame

Companion Libraries

Package Description
@humanspeak/svelte-markdown Markdown renderer with LLM streaming mode (~1.6ms per update)
@humanspeak/svelte-virtual-list General-purpose virtual list for non-chat use cases

Svelte 5 ecosystem

Part of the Humanspeak family of runes-native Svelte 5 packages:

Package Description
@humanspeak/svelte-markdown Runtime markdown renderer for Svelte
@humanspeak/svelte-virtual-list Virtual scrolling for Svelte
@humanspeak/svelte-motion Framer Motion for Svelte 5
@humanspeak/svelte-headless-table Headless data tables for Svelte
@humanspeak/svelte-diff-match-patch Diff comparison for Svelte
@humanspeak/svelte-purify HTML sanitisation for Svelte
@humanspeak/svelte-virtual-chat β€” this package Virtual chat viewport for Svelte 5
@humanspeak/memory-cache In-memory cache for TypeScript
@humanspeak/svelte-json-view-lite JSON tree viewer for Svelte 5
@humanspeak/svelte-scoped-props Scoped class props for Svelte

License

MIT Β© Humanspeak, Inc.

Credits

Made with ❀️ by Humanspeak

About

πŸ’¬ A performant virtual chat component for Svelte applications - efficiently render large scrollable chat interfaces with minimal memory usage

Topics

Resources

License

Stars

4 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors