Skip to content

VirtualList

The core component. Renders only the rows visible in the viewport plus an overscan buffer. ResizeObserver measures each row after mount so variable-height rows are handled automatically.

Props

PropTypeDefaultDescription
itemsT[]Data array
keyFieldstring'id'Field used as the :key for each row
estimatedItemSizenumber | (item, index) => number50Initial size estimate per row — height, or width when horizontal is set
overscannumber3Extra rows rendered above/below viewport
minHeightnumber0Minimum total list height in px (vertical mode)
minWidthnumber0Minimum total list width in px (horizontal mode)
scrollElementHTMLElement | nullnullExternal scroll container (mutually exclusive with pageMode)
pageModebooleanfalseUse window as the scroll container. Fixed at mount — see note below
horizontalbooleanfalseVirtualize along scrollLeft/clientWidth instead of scrollTop/clientHeight. RTL-safe. Fixed at mount — see note below
isLoadingbooleanfalseShows the #skeleton slot when items is empty
restoreKeystringKey used to save/restore scroll position in sessionStorage
ssrPreloadCountnumber20Number of rows rendered on the server
recyclePoolbooleanfalseReuse DOM nodes instead of unmounting them (better scroll FPS, disables key-based transitions)
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity, clearing ~150ms after scrolling settles

pageMode and horizontal are read once at mount, like an axis/mode choice rather than a live-reactive prop. If your UI lets users toggle horizontal at runtime, bind :key to the value driving it (e.g. :key="layout") so Vue remounts the component instead of leaving the previous axis wired up — see the VirtualList demo tab's Layout toggle for a working example.

Slots

SlotScopeDescription
#default{ item: T, index: number, style }Row content
#emptyRendered when items is empty and not loading
#skeletonRendered when items is empty and isLoading is true
#loadingRendered at the bottom while isLoading is true

Emits

EventPayloadDescription
scrollEventNative scroll event
visible-range-change{ start: number; end: number }Fires when the visible slice changes

Exposed API (VirtualListExpose)

ts
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

const listRef = ref<VirtualListExpose | null>(null)

listRef.value?.scrollTo(index, align) // 'start' | 'center' | 'end' | 'auto'
listRef.value?.scrollTo(index, 'start', { behavior: 'smooth' }) // native smooth-scroll animation
listRef.value?.scrollToOffset(px) // scroll to a pixel offset
listRef.value?.scrollToOffset(px, { behavior: 'smooth' })
listRef.value?.measureItem(index, height) // manually set a row height
listRef.value?.getScrollElement() // the element that actually scrolls — pair with VirtualScrollbar

behavior: 'smooth' defaults to 'auto' (instant jump, unchanged from before). Smooth-scrolling to a far-off virtualized index targets the current estimated offset. A direct scrollTop write (e.g. from the anchor-compensation described below) would otherwise cancel an in-progress native smooth-scroll, so that compensation is suppressed for ~1s after any behavior: 'smooth' call.

Examples

Fixed-height rows:

vue
<VirtualList :items="rows" :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }">
    <div class="row">{{ item.name }}</div>
  </template>
</VirtualList>

Variable-height rows (per-item estimate):

vue
<VirtualList
  :items="posts"
  :estimated-item-size="(item) => (item.isExpanded ? 200 : 60)"
  style="height: 600px"
>
  <template #default="{ item }">
    <PostCard :post="item" />
  </template>
</VirtualList>

External scroll container:

vue
<div ref="scrollEl" style="overflow-y: auto; height: 400px">
  <VirtualList :items="rows" :scroll-element="scrollEl" :estimated-item-size="48">
    <template #default="{ item }"><Row :data="item" /></template>
  </VirtualList>
</div>

Page-mode (whole page scrolls):

vue
<VirtualList :items="rows" page-mode :estimated-item-size="80">
  <template #default="{ item }"><Article :post="item" /></template>
</VirtualList>

Skeleton loading state:

vue
<VirtualList :items="items" :is-loading="isLoading" :estimated-item-size="56" style="height: 500px">
  <template #default="{ item }"><Item :data="item" /></template>
  <template #skeleton>
    <SkeletonRow v-for="i in 10" :key="i" />
  </template>
  <template #empty>
    <div>No items found.</div>
  </template>
</VirtualList>

Scroll restoration:

vue
<VirtualList :items="rows" restore-key="my-list" :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }"><Row :data="item" /></template>
</VirtualList>

DOM recycling pool (high-FPS heavy rows):

vue
<VirtualList :items="rows" recycle-pool :estimated-item-size="80" style="height: 500px">
  <template #default="{ item }"><HeavyRow :data="item" /></template>
</VirtualList>

Note: recyclePool reuses DOM nodes so keyed Vue transitions on individual items won't work. Use it when render performance matters more than per-item animations.

Motion blur while scrolling fast:

vue
<VirtualList :items="rows" motion-blur :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }">
    <div class="row">{{ item.name }}</div>
  </template>
</VirtualList>

Horizontal layout (card strip):

vue
<VirtualList horizontal :items="cards" :estimated-item-size="220" style="height: 240px">
  <template #default="{ item }">
    <div class="card" style="width: 220px; height: 100%">{{ item.title }}</div>
  </template>
</VirtualList>

In horizontal mode, estimatedItemSize is a width estimate and each row is positioned via inset-inline-start (RTL-safe) with height: 100% — the slot content is responsible for its own width (fixed, or measured dynamically via ResizeObserver, same as row heights in vertical mode). Pair with <VirtualScrollbar orientation="horizontal"> for a themable scrollbar.