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
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Data array |
keyField | string | 'id' | Field used as the :key for each row |
estimatedItemSize | number | (item, index) => number | 50 | Initial size estimate per row — height, or width when horizontal is set |
overscan | number | 3 | Extra rows rendered above/below viewport |
minHeight | number | 0 | Minimum total list height in px (vertical mode) |
minWidth | number | 0 | Minimum total list width in px (horizontal mode) |
scrollElement | HTMLElement | null | null | External scroll container (mutually exclusive with pageMode) |
pageMode | boolean | false | Use window as the scroll container. Fixed at mount — see note below |
horizontal | boolean | false | Virtualize along scrollLeft/clientWidth instead of scrollTop/clientHeight. RTL-safe. Fixed at mount — see note below |
isLoading | boolean | false | Shows the #skeleton slot when items is empty |
restoreKey | string | — | Key used to save/restore scroll position in sessionStorage |
ssrPreloadCount | number | 20 | Number of rows rendered on the server |
recyclePool | boolean | false | Reuse DOM nodes instead of unmounting them (better scroll FPS, disables key-based transitions) |
motionBlur | boolean | false | Apply a CSS blur that scales with scroll velocity, clearing ~150ms after scrolling settles |
pageModeandhorizontalare read once at mount, like an axis/mode choice rather than a live-reactive prop. If your UI lets users togglehorizontalat runtime, bind:keyto 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
| Slot | Scope | Description |
|---|---|---|
#default | { item: T, index: number, style } | Row content |
#empty | — | Rendered when items is empty and not loading |
#skeleton | — | Rendered when items is empty and isLoading is true |
#loading | — | Rendered at the bottom while isLoading is true |
Emits
| Event | Payload | Description |
|---|---|---|
scroll | Event | Native scroll event |
visible-range-change | { start: number; end: number } | Fires when the visible slice changes |
Exposed API (VirtualListExpose)
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 directscrollTopwrite (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 anybehavior: 'smooth'call.
Examples
Fixed-height rows:
<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):
<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:
<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):
<VirtualList :items="rows" page-mode :estimated-item-size="80">
<template #default="{ item }"><Article :post="item" /></template>
</VirtualList>Skeleton loading state:
<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:
<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):
<VirtualList :items="rows" recycle-pool :estimated-item-size="80" style="height: 500px">
<template #default="{ item }"><HeavyRow :data="item" /></template>
</VirtualList>Note:
recyclePoolreuses 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:
<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):
<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,
estimatedItemSizeis a width estimate and each row is positioned viainset-inline-start(RTL-safe) withheight: 100%— the slot content is responsible for its ownwidth(fixed, or measured dynamically viaResizeObserver, same as row heights in vertical mode). Pair with<VirtualScrollbar orientation="horizontal">for a themable scrollbar.