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: