InfiniteLoader, VirtualSelect & VirtualScrollbar
InfiniteLoader
Wraps VirtualList and calls onLoadMore when the user scrolls within threshold pixels of the bottom (or top, or both).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Current data array |
onLoadMore | () => Promise<void> | — | Called when more data is needed |
isLoading | boolean | — | Whether a load is in progress |
hasMore | boolean | — | Whether more data exists |
threshold | number | 200 | Distance from edge (px) at which to trigger onLoadMore |
direction | 'down' | 'up' | 'both' | 'down' | Which edge(s) trigger loading |
estimatedItemSize | number | 50 | Estimated row height |
overscan | number | 3 | Extra rows outside viewport |
keyField | string | 'id' | Row key field |
motionBlur | boolean | false | Apply a CSS blur that scales with scroll velocity while scrolling fast |
Slots
| Slot | Scope | Description |
|---|---|---|
#default | { item: T, index: number, style } | Row content |
#loading-indicator | — | Custom loading spinner (shown at top/bottom depending on direction) |
#empty | — | Empty state |
Emits
scroll, visible-range-change.
Exposed API
loaderRef.value?.scrollTo(index, align)
loaderRef.value?.scrollTo(index, 'start', { behavior: 'smooth' })
loaderRef.value?.scrollToOffset(px)
loaderRef.value?.getScrollElement()Example
<script setup lang="ts">
import { ref } from 'vue'
import { InfiniteLoader } from 'vue-virtual-scroller-kit'
interface Post {
id: number
title: string
}
const posts = ref<Post[]>([])
const isLoading = ref(false)
const hasMore = ref(true)
let page = 0
async function loadMore() {
if (isLoading.value || !hasMore.value) return
isLoading.value = true
try {
const res = await fetch(`/api/posts?page=${page}`)
const data: Post[] = await res.json()
posts.value = [...posts.value, ...data]
hasMore.value = data.length === 20
page++
} finally {
isLoading.value = false
}
}
await loadMore()
</script>
<template>
<InfiniteLoader
:items="posts"
:on-load-more="loadMore"
:is-loading="isLoading"
:has-more="hasMore"
:estimated-item-size="72"
style="height: 600px"
>
<template #default="{ item }">
<div class="post-row">{{ item.title }}</div>
</template>
<template #loading-indicator>
<div style="padding: 16px; text-align: center">Loading…</div>
</template>
</InfiniteLoader>
</template>VirtualSelect
A searchable select input backed by a virtualized dropdown. Handles hundreds of thousands of options without DOM overhead.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | T[] | — | Option objects |
modelValue | T | null | null | Currently selected option |
labelField | string | 'label' | Field to display in the trigger and dropdown |
valueField | string | 'value' | Field used for equality comparison |
placeholder | string | 'Select an option…' | Placeholder text |
disabled | boolean | false | Disable the select |
clearable | boolean | false | Show a clear button when a value is selected |
searchable | boolean | true | Show a search input when the dropdown opens |
estimatedItemSize | number | 36 | Estimated option row height |
maxVisibleRows | number | 8 | Max rows shown before the dropdown scrolls |
motionBlur | boolean | false | Apply a CSS blur that scales with scroll velocity while scrolling fast |
remote | boolean | false | Skip client-side filtering — options is rendered as-is; you update it yourself in response to search |
debounceMs | number | 0 | Delay before the search event fires after typing stops (coalesces keystrokes for a server round-trip). 0 keeps today's synchronous behavior |
isLoading | boolean | false | Shows the #loading slot in the dropdown, checked before the #empty slot so an in-flight remote search doesn't flash "No options" |
Emits
| Event | Payload |
|---|---|
update:modelValue | T | null |
change | T | null |
search | string |
Slots
| Slot | Scope | Description |
|---|---|---|
#default | { option: T, index: number, selected: boolean } | Custom option row |
#empty | — | Shown when filteredOptions is empty and not loading |
#loading | — | Shown while isLoading is true, in place of the option list |
Exposed API
selectRef.value?.open()
selectRef.value?.close()
selectRef.value?.getScrollElement() // pair with VirtualScrollbarExample
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualSelect } from 'vue-virtual-scroller-kit'
interface Country {
value: string
label: string
flag: string
}
const countries: Country[] = [
{ value: 'us', label: 'United States', flag: '🇺🇸' },
{ value: 'de', label: 'Germany', flag: '🇩🇪' },
// … hundreds more
]
const selected = ref<Country | null>(null)
</script>
<template>
<VirtualSelect
v-model="selected"
:options="countries"
label-field="label"
value-field="value"
clearable
style="width: 300px"
>
<template #default="{ option }"> {{ option.flag }} {{ option.label }} </template>
</VirtualSelect>
</template>Async/remote search — options is populated from a server, filtering happens there instead of client-side:
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualSelect } from 'vue-virtual-scroller-kit'
interface Country {
value: string
label: string
}
const selected = ref<Country | null>(null)
const results = ref<Country[]>([])
const isLoading = ref(false)
let requestId = 0
async function onSearch(query: string) {
const id = ++requestId
isLoading.value = true
try {
const res = await fetch(`/api/countries?q=${encodeURIComponent(query)}`)
const data: Country[] = await res.json()
if (id !== requestId) return // a newer keystroke already fired another request
results.value = data
} finally {
if (id === requestId) isLoading.value = false
}
}
</script>
<template>
<VirtualSelect
v-model="selected"
:options="results"
remote
:debounce-ms="300"
:is-loading="isLoading"
label-field="label"
value-field="value"
style="width: 300px"
@search="onSearch"
>
<template #loading>Searching…</template>
</VirtualSelect>
</template>VirtualScrollbar
A themable custom scrollbar overlay. Decoupled from useVirtualScroll — it works with the scroll element exposed by any component above (via getScrollElement()), or any scrollable element you pass directly. Renders a track + draggable thumb sized and positioned from scrollHeight/clientHeight/scrollTop, and drags with pointer events.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
target | () => HTMLElement | null | — | Returns the scroll element to sync with |
orientation | 'vertical' | 'horizontal' | 'vertical' | Scroll axis to track |
minThumbSize | number | 24 | Minimum thumb size in px, so a huge list doesn't shrink it to an ungrabbable sliver |
CSS custom properties
| Property | Default | Description |
|---|---|---|
--vvsk-scrollbar-size | 10px | Thickness of the track/thumb |
--vvsk-scrollbar-track | transparent | Track background |
--vvsk-scrollbar-thumb | rgb(255 255 255 / 25%) | Thumb background |
--vvsk-scrollbar-thumb-hover | rgb(255 255 255 / 40%) | Thumb background while hovered/dragged |
Example
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, VirtualScrollbar } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'
const listRef = ref<VirtualListExpose | null>(null)
const items = Array.from({ length: 10_000 }, (_, i) => ({ id: i, text: `Row ${i + 1}` }))
</script>
<template>
<div style="position: relative; display: flex; height: 500px">
<VirtualList
ref="listRef"
:items="items"
:estimated-item-size="48"
class="vvsk-scrollbar-hidden"
style="flex: 1"
>
<template #default="{ item }">
<div style="padding: 12px 16px; border-bottom: 1px solid #eee">{{ item.text }}</div>
</template>
</VirtualList>
<VirtualScrollbar :target="() => listRef?.getScrollElement() ?? null" />
</div>
</template>Hide the native scrollbar on the paired container when using VirtualScrollbar (optional — the two can coexist if you want both):
.vvsk-scrollbar-hidden {
scrollbar-width: none; /* Firefox */
}
.vvsk-scrollbar-hidden::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}