Svelte Virtual List logo

Infinite Scroll

Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.

Interactive Demo

Items: 50 | Loads: 0 | Scroll for more...
Item 0
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11
Item 12
Item 13
Item 14
Item 15
Item 16
Item 17
Item 18
Item 19
Item 20

Basic Usage

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

    let items = $state([...initialItems])
    let hasMore = $state(true)

    async function loadMore() {
        const newItems = await fetchMoreItems()
        items = [...items, ...newItems]

        if (newItems.length === 0) {
            hasMore = false
        }
    }
</script>

<VirtualList
    {items}
    onLoadMore={loadMore}
    loadMoreThreshold={20}
    {hasMore}
>
    {#snippet renderItem(item)}
        <div>{item.text}</div>
    {/snippet}
</VirtualList>
<script lang="ts">
    import VirtualList from '@humanspeak/svelte-virtual-list'

    let items = $state([...initialItems])
    let hasMore = $state(true)

    async function loadMore() {
        const newItems = await fetchMoreItems()
        items = [...items, ...newItems]

        if (newItems.length === 0) {
            hasMore = false
        }
    }
</script>

<VirtualList
    {items}
    onLoadMore={loadMore}
    loadMoreThreshold={20}
    {hasMore}
>
    {#snippet renderItem(item)}
        <div>{item.text}</div>
    {/snippet}
</VirtualList>

Props

PropTypeDefaultDescription
onLoadMore() => void \| Promise<void>-Callback when more data is needed (supports async)
loadMoreThresholdnumber20Number of items from the end to trigger onLoadMore
hasMorebooleantrueSet to false when all data has been loaded

Behavior

  • Triggers when scrolling near the end - The callback fires when the user scrolls within loadMoreThreshold items of the end.
  • Automatic initial trigger - If the initial items are below the threshold, onLoadMore is called automatically on mount.
  • Prevents concurrent calls - While onLoadMore is running (for async functions), additional calls are prevented.
  • Works in both modes - Supports both topToBottom and bottomToTop scroll modes.

Complete Example

Here’s a more complete example with loading states and error handling:

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

    type Item = { id: number; text: string }

    let items = $state<Item[]>([])
    let hasMore = $state(true)
    let isLoading = $state(false)
    let error = $state<string | null>(null)
    let page = $state(1)

    // Initial load
    $effect(() => {
        loadInitial()
    })

    async function loadInitial() {
        isLoading = true
        try {
            const response = await fetch('/api/items?page=1')
            const data = await response.json()
            items = data.items
            hasMore = data.hasMore
            page = 1
        } catch (e) {
            error = 'Failed to load items'
        } finally {
            isLoading = false
        }
    }

    async function loadMore() {
        if (isLoading) return

        isLoading = true
        error = null

        try {
            const nextPage = page + 1
            const response = await fetch(`/api/items?page=${nextPage}`)
            const data = await response.json()

            items = [...items, ...data.items]
            hasMore = data.hasMore
            page = nextPage
        } catch (e) {
            error = 'Failed to load more items'
        } finally {
            isLoading = false
        }
    }
</script>

{#if error}
    <div class="error">
        {error}
        <button onclick={loadMore}>Retry</button>
    </div>
{/if}

<VirtualList
    {items}
    onLoadMore={loadMore}
    loadMoreThreshold={10}
    {hasMore}
>
    {#snippet renderItem(item)}
        <div class="item">{item.text}</div>
    {/snippet}
</VirtualList>

{#if isLoading}
    <div class="loading">Loading...</div>
{/if}
<script lang="ts">
    import VirtualList from '@humanspeak/svelte-virtual-list'

    type Item = { id: number; text: string }

    let items = $state<Item[]>([])
    let hasMore = $state(true)
    let isLoading = $state(false)
    let error = $state<string | null>(null)
    let page = $state(1)

    // Initial load
    $effect(() => {
        loadInitial()
    })

    async function loadInitial() {
        isLoading = true
        try {
            const response = await fetch('/api/items?page=1')
            const data = await response.json()
            items = data.items
            hasMore = data.hasMore
            page = 1
        } catch (e) {
            error = 'Failed to load items'
        } finally {
            isLoading = false
        }
    }

    async function loadMore() {
        if (isLoading) return

        isLoading = true
        error = null

        try {
            const nextPage = page + 1
            const response = await fetch(`/api/items?page=${nextPage}`)
            const data = await response.json()

            items = [...items, ...data.items]
            hasMore = data.hasMore
            page = nextPage
        } catch (e) {
            error = 'Failed to load more items'
        } finally {
            isLoading = false
        }
    }
</script>

{#if error}
    <div class="error">
        {error}
        <button onclick={loadMore}>Retry</button>
    </div>
{/if}

<VirtualList
    {items}
    onLoadMore={loadMore}
    loadMoreThreshold={10}
    {hasMore}
>
    {#snippet renderItem(item)}
        <div class="item">{item.text}</div>
    {/snippet}
</VirtualList>

{#if isLoading}
    <div class="loading">Loading...</div>
{/if}

Cursor-Based Pagination

For APIs that use cursor-based pagination:

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

    let items = $state<Item[]>([])
    let cursor = $state<string | null>(null)
    let hasMore = $state(true)

    async function loadMore() {
        const params = new URLSearchParams({ limit: '50' })
        if (cursor) {
            params.set('cursor', cursor)
        }

        const response = await fetch(`/api/items?${params}`)
        const data = await response.json()

        items = [...items, ...data.items]
        cursor = data.nextCursor
        hasMore = data.hasMore
    }
</script>

<VirtualList {items} onLoadMore={loadMore} {hasMore}>
    {#snippet renderItem(item)}
        <div>{item.text}</div>
    {/snippet}
</VirtualList>
<script lang="ts">
    import VirtualList from '@humanspeak/svelte-virtual-list'

    let items = $state<Item[]>([])
    let cursor = $state<string | null>(null)
    let hasMore = $state(true)

    async function loadMore() {
        const params = new URLSearchParams({ limit: '50' })
        if (cursor) {
            params.set('cursor', cursor)
        }

        const response = await fetch(`/api/items?${params}`)
        const data = await response.json()

        items = [...items, ...data.items]
        cursor = data.nextCursor
        hasMore = data.hasMore
    }
</script>

<VirtualList {items} onLoadMore={loadMore} {hasMore}>
    {#snippet renderItem(item)}
        <div>{item.text}</div>
    {/snippet}
</VirtualList>

Bottom-to-Top with Infinite Scroll

For chat-style interfaces that load older messages:

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

    let messages = $state<Message[]>([])
    let hasMoreOlder = $state(true)

    async function loadOlderMessages() {
        const oldestId = messages[0]?.id
        const older = await fetchMessagesBefore(oldestId)

        // Prepend older messages
        messages = [...older, ...messages]

        if (older.length === 0) {
            hasMoreOlder = false
        }
    }
</script>

<VirtualList
    items={messages}
    mode="bottomToTop"
    onLoadMore={loadOlderMessages}
    hasMore={hasMoreOlder}
    loadMoreThreshold={15}
>
    {#snippet renderItem(message)}
        <div class="message">{message.text}</div>
    {/snippet}
</VirtualList>
<script lang="ts">
    import VirtualList from '@humanspeak/svelte-virtual-list'

    let messages = $state<Message[]>([])
    let hasMoreOlder = $state(true)

    async function loadOlderMessages() {
        const oldestId = messages[0]?.id
        const older = await fetchMessagesBefore(oldestId)

        // Prepend older messages
        messages = [...older, ...messages]

        if (older.length === 0) {
            hasMoreOlder = false
        }
    }
</script>

<VirtualList
    items={messages}
    mode="bottomToTop"
    onLoadMore={loadOlderMessages}
    hasMore={hasMoreOlder}
    loadMoreThreshold={15}
>
    {#snippet renderItem(message)}
        <div class="message">{message.text}</div>
    {/snippet}
</VirtualList>

Integration Guides

For real-world integrations with backend services: