Infinite Scroll

Automatic pagination with loading indicators
Demonstrates the load event and loading prop/slot. Currently showing 50 items. When you reach the end of the list, more items are automatically fetched and appended. The demo source is capped at 500 items - the loading slot only appears while auto-loading is on and there is still data to fetch.

How to build a feature like this

The goal is a list that keeps fetching and appending as the user nears the bottom, so there is no visible "end of data" pause. The one mechanism that makes it work is the load event: the list engine watches its own scroll state and, whenever the remaining distance to the bottom of the content drops to loadDistance, emits load with the axis that crossed the threshold. Your handler fetches and appends rows, and the loading prop reveals the #loading slot and suppresses repeated load events while a request is in flight. The main thing to design around: an "endless" feed is still a finite source, so it must be able to signal that no more data exists - otherwise the reserved loading slot would keep showing forever.

1. Constrain the scroll box and model a source that can end

As with any virtualized list the scroll host needs a definite height (a flex parent needs min-height: 0 so the box can shrink). Infinite loading also requires real data objects: because every appended row shows distinct content, a sparse new Array(n) placeholder is not enough here. Keep your rows in a ref array and replace that array on every page append (see step 2) so the list observes the change.

2. Fetch on demand with a guarded async loader

Bind :loading="loading", :load-distance, and handle @load. Set load-distance to a lead-in of at least one viewport, measured in display pixels (the default is 200; 300 suits a ~600px viewport). Two re-entrancy guards matter: set loadingtrue for the whole fetch (the engine then suppresses further load events), and also check hasMore - because the event can fire again immediately after an append when you are still inside loadDistance of the new end. Prefer reassigning items.value = [...items, ...chunk] over mutating in place, and always reset loading in a finally so an error does not leave the spinner stuck.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { ref } from 'vue';

const items = ref(Array.from({ length: 40 }, (_, i) => `Item ${ i }`));
const loading = ref(false); // shows #loading and suppresses 'load' re-fires
const hasMore = ref(true);  // a finite source must be able to signal its end
const PAGE = 20;
const LIMIT = 500;

async function loadMore(direction: 'vertical' | 'horizontal') {
  // 'load' can fire mid-fetch and again right after an append if you are still
  // within loadDistance of the (new) end, so guard on both conditions.
  if (direction !== 'vertical' || loading.value || !hasMore.value) return;
  loading.value = true;
  try {
    await new Promise((r) => setTimeout(r, 800)); // simulated request
    if (items.value.length >= LIMIT) { hasMore.value = false; return; }
    const start = items.value.length;
    const chunk = Array.from(
      { length: Math.min(PAGE, LIMIT - start) },
      (_, i) => `Item ${ start + i }`,
    );
    items.value = [...items.value, ...chunk]; // assign a NEW array
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="feed"
    :items="items"
    :item-size="60"
    :loading="loading"
    :load-distance="300"
    @load="loadMore"
  >
    <template #item="{ item, index }">
      <div class="row">#{{ index }} · {{ item }}</div>
    </template>

    <!-- Kept mounted but hidden (visibility) while idle so it reserves height;
         drop it via v-if="hasMore" once exhausted to free that space. -->
    <template v-if="hasMore" #loading>
      <div class="spinner">Fetching more…</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.feed {
  height: 480px;
} /* the scroll viewport needs a definite height */
.row {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  height: 100%;
  padding-inline: 1rem;
  border-bottom: 1px solid rgb(0 0 0 / .1);
}
.spinner {
  padding: 1rem;
  text-align: center;
}
</style>

3. The loading slot reserves space and must be gated by data

The #loading slot is always rendered once you provide it: while loading is false it is kept mounted and hidden with visibility: hidden (class virtual-scroll-loading--hidden), so it still reserves its height below the items - which is also why the End key can include its size in the scroll target. Because it always reserves space, stop providing it once there is no more data: put your own condition on the slot (e.g. v-if="autoLoadEnabled && hasMore" on <template #loading>) so that disabling auto-loading or exhausting the source makes the reserved space disappear instead of leaving a permanent empty footer.

4. Tune loadDistance against buffers and row size

loadDistance decides when to fetch; buffer-after/buffer-before decide how many extra rows stay mounted beyond each edge (row counts, default 5). Set loadDistance to at least roughly one viewport so the request starts while content is still visible and typically resolves before the user reaches the new tail; a value that is too small means the user reaches the actual end and waits on the spinner. Because the distance is measured from the total content end in pixels, taller rows consume that budget faster, so relate it to your item-size rather than to a row count. The two interact at the boundary: a generous buffer-after pre-mounts the rows right at the fetch threshold, so newly appended items appear without a blank flash when you cross it.

5. Gate the automatic path behind your own switch

load is the automatic trigger, but you can also offer a manual "Load More" button or an auto-load toggle that route through the same guarded handler. Because load also fires for the horizontal axis in two-directional lists, branch on the axis argument. The loading prop also drives the button's disabled state, so a request cannot be started twice.

<script setup lang="ts">
// Optionally gate the automatic path behind a user-facing "auto-load" switch
// while still offering a manual button; both funnel into one guarded fetcher.
const autoLoad = ref(true);

function onLoad(axis: 'vertical' | 'horizontal') {
  if (autoLoad.value && axis === 'vertical') {
    void loadMore(axis);
  }
}
</script>

<template>
  <!-- :loading also disables the button while a request is running. -->
  <button :disabled="loading" @click="loadMore('vertical')">Load more</button>

  <label><input v-model="autoLoad" type="checkbox" /> Auto-load on scroll</label>
</template>
#0Initial Item 0
#1Initial Item 1
#2Initial Item 2
#3Initial Item 3
#4Initial Item 4
Fetching more items...
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y