Skip to content

InfiniteLoader, VirtualSelect & VirtualScrollbar

InfiniteLoader

Wraps VirtualList and calls onLoadMore when the user scrolls within threshold pixels of the bottom (or top, or both).

Props

PropTypeDefaultDescription
itemsT[]Current data array
onLoadMore() => Promise<void>Called when more data is needed
isLoadingbooleanWhether a load is in progress
hasMorebooleanWhether more data exists
thresholdnumber200Distance from edge (px) at which to trigger onLoadMore
direction'down' | 'up' | 'both''down'Which edge(s) trigger loading
estimatedItemSizenumber50Estimated row height
overscannumber3Extra rows outside viewport
keyFieldstring'id'Row key field
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity while scrolling fast

Slots

SlotScopeDescription
#default{ item: T, index: number, style }Row content
#loading-indicatorCustom loading spinner (shown at top/bottom depending on direction)
#emptyEmpty state

Emits

scroll, visible-range-change.

Exposed API

ts
loaderRef.value?.scrollTo(index, align)
loaderRef.value?.scrollTo(index, 'start', { behavior: 'smooth' })
loaderRef.value?.scrollToOffset(px)
loaderRef.value?.getScrollElement()

Example

vue
<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

PropTypeDefaultDescription
optionsT[]Option objects
modelValueT | nullnullCurrently selected option
labelFieldstring'label'Field to display in the trigger and dropdown
valueFieldstring'value'Field used for equality comparison
placeholderstring'Select an option…'Placeholder text
disabledbooleanfalseDisable the select
clearablebooleanfalseShow a clear button when a value is selected
searchablebooleantrueShow a search input when the dropdown opens
estimatedItemSizenumber36Estimated option row height
maxVisibleRowsnumber8Max rows shown before the dropdown scrolls
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity while scrolling fast
remotebooleanfalseSkip client-side filtering — options is rendered as-is; you update it yourself in response to search
debounceMsnumber0Delay before the search event fires after typing stops (coalesces keystrokes for a server round-trip). 0 keeps today's synchronous behavior
isLoadingbooleanfalseShows the #loading slot in the dropdown, checked before the #empty slot so an in-flight remote search doesn't flash "No options"

Emits

EventPayload
update:modelValueT | null
changeT | null
searchstring

Slots

SlotScopeDescription
#default{ option: T, index: number, selected: boolean }Custom option row
#emptyShown when filteredOptions is empty and not loading
#loadingShown while isLoading is true, in place of the option list

Exposed API

ts
selectRef.value?.open()
selectRef.value?.close()
selectRef.value?.getScrollElement() // pair with VirtualScrollbar

Example

vue
<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 searchoptions is populated from a server, filtering happens there instead of client-side:

vue
<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

PropTypeDefaultDescription
target() => HTMLElement | nullReturns the scroll element to sync with
orientation'vertical' | 'horizontal''vertical'Scroll axis to track
minThumbSizenumber24Minimum thumb size in px, so a huge list doesn't shrink it to an ungrabbable sliver

CSS custom properties

PropertyDefaultDescription
--vvsk-scrollbar-size10pxThickness of the track/thumb
--vvsk-scrollbar-tracktransparentTrack background
--vvsk-scrollbar-thumbrgb(255 255 255 / 25%)Thumb background
--vvsk-scrollbar-thumb-hoverrgb(255 255 255 / 40%)Thumb background while hovered/dragged

Example

vue
<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):

css
.vvsk-scrollbar-hidden {
  scrollbar-width: none; /* Firefox */
}
.vvsk-scrollbar-hidden::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Edge */
}