Composables
useVirtualScroll
Низкоуровневый composable, на котором работают все компоненты. Используйте его, когда нужно построить кастомный виртуальный контейнер.
Опции
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
itemCount | number | Ref<number> | — | Общее число элементов |
estimatedItemSize | SizeProvider | Ref<SizeProvider> | 50 | Оценочная высота элемента: число или (index) => number. Ref вызывает полную перестройку при изменении |
overscan | number | 3 | Дополнительные элементы, рендерящиеся вне вьюпорта |
getScrollElement | () => HTMLElement | null | — | Возвращает контейнер прокрутки |
pageMode | boolean | false | Использовать window как контейнер прокрутки. Считывается один раз, не реактивно — см. примечание ниже |
horizontal | boolean | false | Виртуализировать scrollLeft/clientWidth вместо scrollTop/clientHeight, RTL-безопасно через normalizeScrollLeft. Считывается один раз, не реактивно — см. примечание ниже |
motionBlur | boolean | false | Отслеживать скорость прокрутки и предоставлять её как blurAmount (px). По умолчанию выключено — нулевая стоимость при отключении |
type SizeProvider = number | ((index: number) => number)
pageModeиhorizontalсчитываются изoptionsодин раз при настройке composable — изменение их на живом инстансе не даёт эффекта. Если нужно переключить оси во время выполнения, перемонтируйте компонент, вызывающийuseVirtualScroll(например, через:key).
Возвращаемое значение
| Свойство | Тип | Описание |
|---|---|---|
visibleRange | Readonly<Ref<VisibleRange>> | { start, end } — индексы первого и последнего видимых элементов |
totalHeight | Readonly<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 | Вручную запустить пересчёт видимого диапазона |
blurAmount | Readonly<Ref<number>> | Текущий радиус motion-blur в px. Всегда 0, если опция motionBlur не включена |
Пример
<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 и управляет индексом в фокусе. Работает с любым компонентом виртуального списка.
Опции
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
itemCount | Ref<number> | number | — | Общее число элементов |
scrollTo | (index, align?) => void | — | Вызывается для прокрутки списка при смене фокуса |
target | Ref<HTMLElement | null> | HTMLElement | document | Элемент, получающий события клавиатуры |
onActivate | (index: number) => void | — | Вызывается на Enter или Space |
onChange | (index: number) => void | — | Вызывается при смене индекса в фокусе |
loop | boolean | false | Переходить ли на противоположный край при достижении границы |
Возвращаемое значение
| Свойство | Тип | Описание |
|---|---|---|
focusedIndex | Readonly<Ref<number>> | Текущий индекс в фокусе, -1, если ничего не в фокусе |
setFocus | (index: number) => void | Программно установить фокус |
isFocused | (index: number) => boolean | В фокусе ли index |
Обрабатываемые клавиши
| Клавиша | Действие |
|---|---|
↑ | Переместить фокус вверх |
↓ | Переместить фокус вниз |
Home | Фокус на первый элемент |
End | Фокус на последний элемент |
PageUp | Прыжок на −10 элементов |
PageDown | Прыжок на +10 элементов |
Enter / Space | Вызвать onActivate |
Пример
<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 и автоматически прокручивает контейнер при перетаскивании у краёв.
Опции
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
items | Ref<T[]> | — | Реактивный массив элементов |
onReorder | (newItems, from, to) => void | — | Вызывается после успешного сброса с переупорядоченным массивом |
isDragDisabled | (item, index) => boolean | — | Верните true, чтобы запретить перетаскивание элемента |
scrollContainer | HTMLElement | Ref<HTMLElement | null> | — | Контейнер для авто-прокрутки при перетаскивании у его краёв |
Возвращаемое значение
| Свойство | Тип | Описание |
|---|---|---|
dragIndex | Readonly<Ref<number>> | Индекс перетаскиваемого элемента, -1 в состоянии покоя |
overIndex | Readonly<Ref<number>> | Индекс текущей цели сброса |
isDragging | Readonly<Ref<boolean>> | Идёт ли сейчас перетаскивание |
ghostStyle | Readonly<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 за кадр).
Пример
<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 с той областью видимости слота индекса строки, которую предоставляет ваш компонент.
Опции
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
items | Ref<T[]> | ComputedRef<T[]> | — | Реактивный массив элементов |
getKey | (item: T, index: number) => string | number | item.id ?? index | Идентичность, используемая для набора выбранных |
multiple | boolean | true | false ограничивает выбор одной строкой; новый вызов toggle заменяет выбор вместо добавления к нему |
Возвращаемое значение
| Свойство | Тип | Описание |
|---|---|---|
selectedKeys | Readonly<Ref<Set<string | number>>> | Текущие выбранные ключи |
selectedItems | ComputedRef<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-диапазона |
Пример
<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 | — | Возвращает контейнер прокрутки для пересечения. Опустите для использования вьюпорта браузера |
rootMargin | string | '0px' | rootMargin IntersectionObserver — расширяет/сжимает эффективные границы root (например, срабатывать чуть раньше) |
threshold | number | number[] | 0 | Доля элемента, которая должна быть видна, чтобы считаться «видимой» |
onVisible | (key: string | number) => void | — | Вызывается, когда отслеживаемый ключ становится видимым |
onHidden | (key: string | number) => void | — | Вызывается, когда отслеживаемый ключ становится скрытым (в том числе через unobserve, пока был видим) |
Возвращаемое значение
| Свойство | Тип | Описание |
|---|---|---|
visibleKeys | Readonly<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:
<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>