Skip to content

Composables

useVirtualScroll

Низкоуровневый composable, на котором работают все компоненты. Используйте его, когда нужно построить кастомный виртуальный контейнер.

Опции

ОпцияТипПо умолчаниюОписание
itemCountnumber | Ref<number>Общее число элементов
estimatedItemSizeSizeProvider | Ref<SizeProvider>50Оценочная высота элемента: число или (index) => number. Ref вызывает полную перестройку при изменении
overscannumber3Дополнительные элементы, рендерящиеся вне вьюпорта
getScrollElement() => HTMLElement | nullВозвращает контейнер прокрутки
pageModebooleanfalseИспользовать window как контейнер прокрутки. Считывается один раз, не реактивно — см. примечание ниже
horizontalbooleanfalseВиртуализировать scrollLeft/clientWidth вместо scrollTop/clientHeight, RTL-безопасно через normalizeScrollLeft. Считывается один раз, не реактивно — см. примечание ниже
motionBlurbooleanfalseОтслеживать скорость прокрутки и предоставлять её как blurAmount (px). По умолчанию выключено — нулевая стоимость при отключении
ts
type SizeProvider = number | ((index: number) => number)

pageMode и horizontal считываются из options один раз при настройке composable — изменение их на живом инстансе не даёт эффекта. Если нужно переключить оси во время выполнения, перемонтируйте компонент, вызывающий useVirtualScroll (например, через :key).

Возвращаемое значение

СвойствоТипОписание
visibleRangeReadonly<Ref<VisibleRange>>{ start, end } — индексы первого и последнего видимых элементов
totalHeightReadonly<Ref<number>>Общий прокручиваемый размер в px по оси прокрутки (высота, либо ширина при horizontal)
offsetTop(index: number) => numberПиксельное смещение элемента по индексу вдоль оси прокрутки (top, либо left при horizontal)
scrollTo(index, align?, options?) => voidПрокрутить к элементу ('start' | 'center' | 'end' | 'auto'). options.behavior'auto' (по умолчанию, мгновенно) или 'smooth'
scrollToOffset(offset: number, options?) => voidПрокрутить к сырому пиксельному смещению. Тот же options.behavior
measureItem(index, height) => voidСообщить измеренную высоту строки
handleScroll() => voidВручную запустить пересчёт видимого диапазона
blurAmountReadonly<Ref<number>>Текущий радиус motion-blur в px. Всегда 0, если опция motionBlur не включена

Пример

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useVirtualScroll } from 'vue-virtual-scroller-kit'

const ITEMS = Array.from({ length: 50_000 }, (_, i) => `Item ${i + 1}`)
const containerRef = ref<HTMLElement | null>(null)

const { visibleRange, totalHeight, offsetTop } = useVirtualScroll({
  itemCount: ITEMS.length,
  estimatedItemSize: 40,
  getScrollElement: () => containerRef.value,
})
</script>

<template>
  <div ref="containerRef" style="height: 500px; overflow-y: auto; position: relative">
    <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
      <div
        v-for="i in visibleRange.end - visibleRange.start + 1"
        :key="visibleRange.start + i - 1"
        :style="{
          position: 'absolute',
          top: `${offsetTop(visibleRange.start + i - 1)}px`,
          width: '100%',
          height: '40px',
        }"
      >
        {{ ITEMS[visibleRange.start + i - 1] }}
      </div>
    </div>
  </div>
</template>

useVirtualKeyboardNav

Composable для навигации с клавиатуры. Подключает обработчики keydown и управляет индексом в фокусе. Работает с любым компонентом виртуального списка.

Опции

ОпцияТипПо умолчаниюОписание
itemCountRef<number> | numberОбщее число элементов
scrollTo(index, align?) => voidВызывается для прокрутки списка при смене фокуса
targetRef<HTMLElement | null> | HTMLElementdocumentЭлемент, получающий события клавиатуры
onActivate(index: number) => voidВызывается на Enter или Space
onChange(index: number) => voidВызывается при смене индекса в фокусе
loopbooleanfalseПереходить ли на противоположный край при достижении границы

Возвращаемое значение

СвойствоТипОписание
focusedIndexReadonly<Ref<number>>Текущий индекс в фокусе, -1, если ничего не в фокусе
setFocus(index: number) => voidПрограммно установить фокус
isFocused(index: number) => booleanВ фокусе ли index

Обрабатываемые клавиши

КлавишаДействие
Переместить фокус вверх
Переместить фокус вниз
HomeФокус на первый элемент
EndФокус на последний элемент
PageUpПрыжок на −10 элементов
PageDownПрыжок на +10 элементов
Enter / SpaceВызвать onActivate

Пример

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { VirtualList, useVirtualKeyboardNav } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

const items = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Item ${i + 1}` })))
const listRef = ref<VirtualListExpose | null>(null)

const { focusedIndex, setFocus, isFocused } = useVirtualKeyboardNav({
  itemCount: computed(() => items.value.length),
  scrollTo: (i, align) => listRef.value?.scrollTo(i, align),
  onActivate: (i) => console.log('Activated', items.value[i]),
  loop: false,
})
</script>

<template>
  <div tabindex="0" style="outline: none; border: 1px solid #ccc">
    <VirtualList ref="listRef" :items="items" :estimated-item-size="48" style="height: 400px">
      <template #default="{ item, index }">
        <div
          :class="{ focused: isFocused(index) }"
          :aria-selected="isFocused(index)"
          role="option"
          @click="setFocus(index)"
        >
          {{ item.label }}
        </div>
      </template>
    </VirtualList>
  </div>
</template>

useDraggableList

Composable для drag-to-reorder на pointer-событиях. Показывает призрачный элемент с фиксированным позиционированием, следующий за курсором, анимирует соседние элементы через translateY и автоматически прокручивает контейнер при перетаскивании у краёв.

Опции

ОпцияТипПо умолчаниюОписание
itemsRef<T[]>Реактивный массив элементов
onReorder(newItems, from, to) => voidВызывается после успешного сброса с переупорядоченным массивом
isDragDisabled(item, index) => booleanВерните true, чтобы запретить перетаскивание элемента
scrollContainerHTMLElement | Ref<HTMLElement | null>Контейнер для авто-прокрутки при перетаскивании у его краёв

Возвращаемое значение

СвойствоТипОписание
dragIndexReadonly<Ref<number>>Индекс перетаскиваемого элемента, -1 в состоянии покоя
overIndexReadonly<Ref<number>>Индекс текущей цели сброса
isDraggingReadonly<Ref<boolean>>Идёт ли сейчас перетаскивание
ghostStyleReadonly<Ref<CSSProperties>>Стили с фиксированным позиционированием для призрачного элемента
getItemStyle(index: number) => CSSPropertiesСтили для каждого элемента: opacity:0 для плейсхолдера, translateY для анимируемых соседей
getItemProps(index: number) => DraggableItemPropsПропы для распространения на каждый перетаскиваемый элемент (data-drag-index, onPointerdown, CSS-классы)

CSS-классы, добавляемые getItemProps

КлассКогда
vvsk-drag--draggingПрименяется к плейсхолдеру (перетаскиваемому элементу)
vvsk-drag--overПрименяется к текущей цели сброса
vvsk-drag--disabledПрименяется, когда isDragDisabled возвращает true

Авто-прокрутка

Когда указан scrollContainer, список автоматически прокручивается вверх или вниз, когда курсор попадает в зону 60 px у краёв контейнера. Скорость прокрутки пропорциональна расстоянию (максимум 14 px за кадр).

Пример

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useDraggableList } from 'vue-virtual-scroller-kit'

interface Card {
  id: number
  label: string
}

const cards = ref<Card[]>(Array.from({ length: 50 }, (_, i) => ({ id: i, label: `Card ${i + 1}` })))
const listRef = ref<HTMLElement | null>(null)

const { isDragging, dragIndex, ghostStyle, getItemStyle, getItemProps } = useDraggableList({
  items: cards,
  scrollContainer: listRef,
  onReorder: (newItems) => {
    cards.value = newItems
  },
})
</script>

<template>
  <div
    ref="listRef"
    style="display: flex; flex-direction: column; gap: 6px; overflow-y: auto; height: 500px"
  >
    <div
      v-for="(card, index) in cards"
      :key="card.id"
      v-bind="getItemProps(index)"
      :style="getItemStyle(index)"
      class="card"
    >
      ⣿ {{ card.label }}
    </div>
  </div>

  <Teleport to="body">
    <div v-if="isDragging && dragIndex >= 0" class="card card--ghost" :style="ghostStyle">
      ⣿ {{ cards[dragIndex]?.label }}
    </div>
  </Teleport>
</template>

<style>
.card {
  padding: 12px 16px;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 6px;
  cursor: grab;
  user-select: none;
}
.card--ghost {
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3);
  transform: scale(1.02);
  pointer-events: none;
}
</style>

useRowSelection

Выбор строк кликом/Shift+клик, не зависящий от структуры данных — работает с VirtualList, VirtualTable или любым обычным массивом. Не привязан к конкретному компоненту: сочетайте isSelected/toggle с той областью видимости слота индекса строки, которую предоставляет ваш компонент.

Опции

ОпцияТипПо умолчаниюОписание
itemsRef<T[]> | ComputedRef<T[]>Реактивный массив элементов
getKey(item: T, index: number) => string | numberitem.id ?? indexИдентичность, используемая для набора выбранных
multiplebooleantruefalse ограничивает выбор одной строкой; новый вызов toggle заменяет выбор вместо добавления к нему

Возвращаемое значение

СвойствоТипОписание
selectedKeysReadonly<Ref<Set<string | number>>>Текущие выбранные ключи
selectedItemsComputedRef<T[]>Текущие выбранные элементы, производные от items + selectedKeys
isSelected(item: T, index: number) => booleanВыбрана ли строка
toggle(item: T, index: number, event?: MouseEvent | KeyboardEvent) => voidПереключить строку. event.shiftKey заполняет диапазон от последней переключённой строки до текущей (соглашение чекбоксов Gmail), поверх уже выбранного
selectAll() => voidВыбрать все строки в items
clearSelection() => voidОчистить выбор и сбросить якорь shift-диапазона

Пример

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useRowSelection } from 'vue-virtual-scroller-kit'

interface Row {
  id: number
  label: string
}
const items = ref<Row[]>(Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Row ${i + 1}` })))

const selection = useRowSelection<Row>({ items })
</script>

<template>
  <VirtualList :items="items" key-field="id" :estimated-item-size="40" style="height: 500px">
    <template #default="{ item, index }">
      <label>
        <input
          type="checkbox"
          :checked="selection.isSelected(item, index)"
          @click="selection.toggle(item, index, $event)"
        />
        {{ item.label }}
      </label>
    </template>
  </VirtualList>
  <p>{{ selection.selectedItems.value.length }} selected</p>
</template>

Клик переключает одну строку на месте; Shift+клик заполняет диапазон от последней переключённой строки. Для VirtualTable сочетайте это с index, теперь доступным в слоте #cell — см. пример выбора строк на странице VirtualTable.

useVisibilityTracker

Отслеживание «вошёл во вьюпорт» / «покинул вьюпорт» по ключу на основе настоящего IntersectionObserver, а не сравнения visibleRange — поэтому точность сохраняется даже при большом буфере overscan или порогах частичной видимости. Не зависит от структуры данных: вы сами решаете, какие элементы observe(), под любым удобным ключом (id строки, индекс, что угодно).

Типичное применение: подсветить пункт навигации/миникарты, элемент оглавления или кнопку «перейти к» на панели управления, пока соответствующая строка реально видна на экране в виртуализированном списке или таблице — мгновенно выключая подсветку, как только строка уходит за пределы экрана.

Опции

ОпцияТипПо умолчаниюОписание
root() => HTMLElement | nullВозвращает контейнер прокрутки для пересечения. Опустите для использования вьюпорта браузера
rootMarginstring'0px'rootMargin IntersectionObserver — расширяет/сжимает эффективные границы root (например, срабатывать чуть раньше)
thresholdnumber | number[]0Доля элемента, которая должна быть видна, чтобы считаться «видимой»
onVisible(key: string | number) => voidВызывается, когда отслеживаемый ключ становится видимым
onHidden(key: string | number) => voidВызывается, когда отслеживаемый ключ становится скрытым (в том числе через unobserve, пока был видим)

Возвращаемое значение

СвойствоТипОписание
visibleKeysReadonly<Ref<Set<string | number>>>Ключи, в данный момент пересекающиеся с root
isVisible(key: string | number) => booleanВиден ли key в данный момент
observe(el: Element | null, key: string | number) => voidНачать отслеживать элемент под key — привязывайте через callback template-ref
unobserve(key: string | number) => voidПрекратить отслеживать key (например, при размонтировании строки)

root может разрешиться уже после того, как запустится собственная настройка этого composable (например, template-ref соседнего VirtualList) — он опрашивается несколько кадров и автоматически перестраивается, если разрешённый элемент root когда-либо меняется (например, после принудительного :key-перемонтирования).

Пример

Отслеживание конкретных строк и отражение их видимости в боковой панели — тот же паттерн, что используется в «Watchlist» демо VirtualList:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useVisibilityTracker } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

interface Row {
  id: number
  title: string
}
const items = ref<Row[]>(
  Array.from({ length: 100_000 }, (_, i) => ({ id: i + 1, title: `Row ${i + 1}` })),
)
const listRef = ref<VirtualListExpose | null>(null)
const watchedIds = ref<Set<number>>(new Set([1, 50_000]))

const tracker = useVisibilityTracker({
  root: () => listRef.value?.getScrollElement() ?? null,
})

// Строки размонтируются при прокрутке за пределы виртуализированного диапазона, поэтому
// отслеживание/снятие происходит на монтировании/размонтировании, а не в предположении,
// что наблюдаемый элемент остаётся живым.
function onRowMount(el: Element, id: number) {
  if (watchedIds.value.has(id)) tracker.observe(el, id)
}
function onRowUnmount(id: number) {
  tracker.unobserve(id)
}
</script>

<template>
  <VirtualList
    ref="listRef"
    :items="items"
    key-field="id"
    :estimated-item-size="48"
    style="height: 500px"
  >
    <template #default="{ item }">
      <div
        :ref="(el) => el && onRowMount(el as Element, item.id)"
        @vue:unmounted="onRowUnmount(item.id)"
        :style="{
          background:
            watchedIds.has(item.id) && tracker.isVisible(item.id) ? '#fef08a' : 'transparent',
        }"
      >
        {{ item.title }}
      </div>
    </template>
  </VirtualList>
</template>