Skip to content

InfiniteLoader, VirtualSelect & VirtualScrollbar

InfiniteLoader

Оборачивает VirtualList и вызывает onLoadMore, когда пользователь прокручивает в пределах threshold пикселей от низа (или верха, либо обоих).

Пропы

ПропТипПо умолчаниюОписание
itemsT[]Текущий массив данных
onLoadMore() => Promise<void>Вызывается, когда нужны дополнительные данные
isLoadingbooleanВыполняется ли загрузка в данный момент
hasMorebooleanДоступны ли ещё данные
thresholdnumber200Расстояние от края (px), при котором срабатывает onLoadMore
direction'down' | 'up' | 'both''down'Какой(-ие) край(-я) запускают загрузку
estimatedItemSizenumber50Оценочная высота строки
overscannumber3Дополнительные строки, рендерящиеся вне вьюпорта
keyFieldstring'id'Поле ключа строки
motionBlurbooleanfalseПрименить CSS-размытие, масштабируемое по скорости прокрутки при быстрой прокрутке

Слоты

СлотОбласть видимостиОписание
#default{ item: T, index: number, style }Содержимое строки
#loading-indicatorКастомный индикатор загрузки (показывается сверху/снизу в зависимости от direction)
#emptyПустое состояние

Emits

scroll, visible-range-change.

Публичный API

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

Пример

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

Поле выбора с поиском на основе виртуализированного выпадающего списка. Обрабатывает сотни тысяч опций без нагрузки на DOM.

Пропы

ПропТипПо умолчаниюОписание
optionsT[]Объекты опций
modelValueT | nullnullТекущая выбранная опция
labelFieldstring'label'Поле, отображаемое в триггере и выпадающем списке
valueFieldstring'value'Поле, используемое для сравнения на равенство
placeholderstring'Select an option…'Текст плейсхолдера
disabledbooleanfalseОтключить select
clearablebooleanfalseПоказывать кнопку очистки, когда значение выбрано
searchablebooleantrueПоказывать поле поиска при открытии выпадающего списка
estimatedItemSizenumber36Оценочная высота строки опции
maxVisibleRowsnumber8Максимум строк, показываемых до прокрутки списка
motionBlurbooleanfalseПрименить CSS-размытие, масштабируемое по скорости прокрутки при быстрой прокрутке
remotebooleanfalseПропустить клиентскую фильтрацию — options рендерится как есть; вы обновляете его сами в ответ на search
debounceMsnumber0Задержка перед срабатыванием события search после остановки ввода (объединяет нажатия клавиш для запроса к серверу). 0 сохраняет текущее синхронное поведение
isLoadingbooleanfalseПоказывает слот #loading в выпадающем списке, проверяется до слота #empty, чтобы выполняющийся удалённый поиск не мигал "No options"

Emits

СобытиеPayload
update:modelValueT | null
changeT | null
searchstring

Слоты

СлотОбласть видимостиОписание
#default{ option: T, index: number, selected: boolean }Кастомная строка опции
#emptyПоказывается, когда filteredOptions пуст и загрузки нет
#loadingПоказывается, пока isLoading истинно, вместо списка опций

Публичный API

ts
selectRef.value?.open()
selectRef.value?.close()
selectRef.value?.getScrollElement() // сочетайте с VirtualScrollbar

Пример

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: '🇩🇪' },
  // … сотни других
]

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>

Асинхронный/удалённый поискoptions заполняется с сервера, фильтрация происходит там, а не на клиенте:

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 // более новое нажатие клавиши уже запустило другой запрос
    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

Темизируемый кастомный оверлей скроллбара. Отделён от useVirtualScroll — он работает с элементом прокрутки, предоставляемым любым из компонентов выше (через getScrollElement()), либо с любым прокручиваемым элементом, переданным напрямую. Рендерит трек + перетаскиваемый ползунок, размер и позиция которого вычисляются из scrollHeight/clientHeight/scrollTop, перетаскивание — через pointer-события.

Пропы

ПропТипПо умолчаниюОписание
target() => HTMLElement | nullВозвращает элемент прокрутки для синхронизации
orientation'vertical' | 'horizontal''vertical'Ось прокрутки для отслеживания
minThumbSizenumber24Минимальный размер ползунка в px, чтобы огромный список не сжимал его до неухватываемой полоски

CSS custom properties

СвойствоПо умолчаниюОписание
--vvsk-scrollbar-size10pxТолщина трека/ползунка
--vvsk-scrollbar-tracktransparentФон трека
--vvsk-scrollbar-thumbrgb(255 255 255 / 25%)Фон ползунка
--vvsk-scrollbar-thumb-hoverrgb(255 255 255 / 40%)Фон ползунка при наведении/перетаскивании

Пример

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>

Скрыть нативный скроллбар на связанном контейнере при использовании VirtualScrollbar (опционально — они могут сосуществовать, если нужны оба):

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