vue-virtual-scroller-kit — виртуализация списков для Vue 3 без компромиссов

Если список содержит тысячи элементов — браузер отрисовывает их все в DOM одновременно. 10 000 строк — это 10 000 <div>-элементов, каждый из которых участвует в layout, paint и composite на каждом кадре. На среднем устройстве это начинает подтормаживать уже на 500–1000 строк с тяжёлым контентом.
Виртуализация решает это радикально: в DOM живут только строки, которые видны на экране плюс небольшой буфер. Остальные существуют только как числа в менеджере позиций. Прокрутка остаётся плавной хоть на миллионе элементов.
Готовых решений для Vue 3 существует несколько, но у каждого были компромиссы. Одни не поддерживают динамическую высоту строк — а именно она нужна в реальных проектах, где контент текстовый и непредсказуемый. Другие не работают с таблицами и сетками. Третьи тянут зависимости или не поддерживают SSR.
Написал vue-virtual-scroller-kit — один пакет, который закрывает все эти сценарии: плоские списки, сгруппированные списки, таблицы, сетки, деревья, бесконечная прокрутка, виртуализированный select, перетаскивание элементов и клавиатурная навигация. Единственная peer-зависимость — Vue 3.
Ядро: PositionManager на сегментном дереве
Большинство библиотек виртуализации хранят высоты строк в обычном массиве и ищут видимый диапазон линейным перебором — O(n). При изменении высоты одной строки — пересчитываются все смещения после неё, снова O(n).
Здесь под капотом — сегментное дерево (Fenwick tree / segment tree) на Float64Array. Обновление высоты строки и запрос смещения — оба O(log n). На 100 000 строк разница ощутима.
ts
import { PositionManager } from 'vue-virtual-scroller-kit'
const manager = new PositionManager(
100_000, // количество элементов
(i) => i % 3 === 0 ? 80 : 48, // переменная высота
)
manager.totalSize // суммарная высота всего списка
manager.getOffset(5000) // пиксельное смещение строки 5000 — O(log n)
manager.findIndex(14400) // какая строка находится на пикселе 14400 — O(log n)
manager.set(42, 120) // обновить высоту строки — O(log n)
Под капотом VirtualList, VirtualTable, VirtualGrid используют этот же класс. Если нужна нестандартная разметка — он доступен напрямую.
VirtualList — основной компонент
Плоский виртуальный список. Измеряет высоту каждой строки через ResizeObserver после рендеринга — не нужно знать высоту заранее. Поддерживает внешний контейнер прокрутки и page-mode когда скроллится вся страница.
vue
<script setup lang="ts">
import { VirtualList } from 'vue-virtual-scroller-kit'
interface Issue { id: number; title: string; body: string; status: string }
const issues = ref<Issue[]>([]) // 50 000 элементов
</script>
<template>
<VirtualList
:items="issues"
:estimated-item-size="72"
style="height: 600px"
@visible-range-change="onRangeChange"
>
<template #default="{ item, index }">
<IssueRow :issue="item" :index="index" />
</template>
<template #empty>
<div class="empty-state">Нет задач</div>
</template>
</VirtualList>
</template>
estimated-item-size — начальная оценка. Как только строки рендерятся, ResizeObserver измеряет реальные высоты и обновляет PositionManager. Это означает что список корректно работает даже если контент непредсказуемой длины — многострочные описания, карточки с разным объёмом данных, строки с развернутым/свёрнутым состоянием.
Пропс estimated-item-size принимает и функцию — для случаев когда можно дать точную оценку без измерения:
ts
:estimated-item-size="(item) => item.isExpanded ? 200 : 56"
Scroll restoration
Добавляешь restore-key — список запоминает позицию в sessionStorage и восстанавливает её при повторном монтировании. Полезно в SPA где пользователь открывает детальную страницу и возвращается назад.
vue
<VirtualList :items="items" restore-key="issues-list" :estimated-item-size="72" style="height: 100%" />
DOM recycling
Пропс recycle-pool включает переиспользование DOM-узлов вместо их демонтирования. При прокрутке строка не удаляется и не создаётся заново — она просто получает новые данные через реактивность. Это убирает garbage collector pressure при быстрой прокрутке тяжёлых строк.
vue
<VirtualList :items="items" recycle-pool :estimated-item-size="80" style="height: 100%">
<template #default="{ item }">
<HeavyRow :data="item" /> <!-- содержит canvas, WebGL, тяжёлые вычисления -->
</template>
</VirtualList>
Компромисс: с recycle-pool ключи элементов не обновляются, поэтому Vue-переходы (<Transition>) на уровне строк не работают. Для списков без анимаций — чистый выигрыш в плавности.
GroupedVirtualList — группировка с анимацией
Список с секциями. Каждая группа может сворачиваться и разворачиваться — с CSS-анимацией fade+slide. Заголовки групп кастомизируются через слот.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { GroupedVirtualList } from 'vue-virtual-scroller-kit'
import type { GroupDef, GroupedVirtualListExpose } from 'vue-virtual-scroller-kit'
interface Task { id: number; title: string; priority: 'high' | 'medium' | 'low' }
const groups = ref<GroupDef<Task>[]>([
{ key: 'high', label: 'Высокий приоритет', items: highTasks },
{ key: 'medium', label: 'Средний', items: mediumTasks, collapsed: true },
{ key: 'low', label: 'Низкий', items: lowTasks, collapsed: true },
])
const listRef = ref<GroupedVirtualListExpose | null>(null)
// развернуть все группы
function expandAll() {
groups.value = groups.value.map((g) => ({ ...g, collapsed: false }))
}
</script>
<template>
<button @click="expandAll">Развернуть все</button>
<GroupedVirtualList
ref="listRef"
:groups="groups"
:estimated-item-size="56"
style="height: 600px"
>
<template #group-header="{ group, toggle, isCollapsed }">
<div class="group-header" @click="toggle">
<span class="chevron" :class="{ 'chevron--rotated': isCollapsed }">▼</span>
{{ group.label }}
<span class="count">{{ group.items.length }}</span>
</div>
</template>
<template #default="{ item }">
<div class="task-row">
<PriorityBadge :priority="item.priority" />
{{ item.title }}
</div>
</template>
</GroupedVirtualList>
</template>
Переключить конкретную группу программно — listRef.value?.toggle('high'). Список виртуализирован — даже с тысячами элементов в каждой группе в DOM живут только видимые строки.
VirtualTable — виртуальная таблица
Таблица с sticky-заголовком, фиксированными колонками (left/right), сортировкой, drag-to-resize, горизонтальной виртуализацией и закреплёнными строками.
ts
const columns: ColumnDef[] = [
{ key: 'id', title: '#', width: 60, fixed: 'left' },
{ key: 'name', title: 'Клиент', width: 180 },
{ key: 'email', title: 'Email', minWidth: 200 },
{ key: 'amount', title: 'Сумма', width: 120 },
{ key: 'status', title: 'Статус', width: 100, fixed: 'right' },
]
vue
<VirtualTable
:columns="columns"
:rows="orders"
sortable
resizable-columns
:sticky-header="true"
style="height: 500px"
@sort-change="onSort"
@column-resize="onResize"
>
<template #cell="{ row, column, value }">
<StatusBadge v-if="column.key === 'status'" :status="value" />
<span v-else>{{ value }}</span>
</template>
</VirtualTable>
Флаг multi-sort включает сортировку по нескольким колонкам через Shift+Click. Событие sort-change возвращает SortChange | SortChange[] — в зависимости от режима. Сортировка данных — на стороне приложения, таблица только сообщает о клике.
virtualize-columns включает горизонтальную виртуализацию — рендерятся только те колонки, которые видны в viewport. Нужно для таблиц с 50+ колонками.
Закреплённые строки
vue
<VirtualTable
:columns="columns"
:rows="rows"
:pinned-top-rows="[totalsRow]"
:pinned-bottom-rows="[grandTotalRow]"
>
Закреплённые строки остаются видимыми при прокрутке — через position: sticky. Рендерятся вне виртуального списка, так что их высота не влияет на виртуализацию.
VirtualGrid — сетка с авто-колонками
Для галерей, карточных интерфейсов, плиточных раскладок. Количество колонок вычисляется автоматически по ширине контейнера и column-width, или задаётся фиксировано.
vue
<VirtualGrid
:items="photos"
:column-width="240"
:row-height="240"
:gap="16"
style="height: 100vh"
>
<template #default="{ item, index, row, col }">
<PhotoCard
:photo="item"
:style="{ animationDelay: `${col * 50}ms` }"
/>
</template>
</VirtualGrid>
При изменении размера окна колонок становится больше или меньше — всё пересчитывается через ResizeObserver. Только видимые строки сеток рендерятся в DOM, каждая строка содержит полный набор ячеек.
VirtualTree — иерархический список
Дерево файлов, организационная структура, вложенные категории. Поддерживает ленивую загрузку дочерних узлов — нужен только onLoadChildren.
ts
interface FileNode { name: string; type: 'file' | 'folder'; size?: number }
const nodes: TreeNode<FileNode>[] = [
{
id: 1,
data: { name: 'src', type: 'folder' },
children: [
{ id: 2, data: { name: 'components', type: 'folder' }, hasChildren: true },
{ id: 3, data: { name: 'main.ts', type: 'file', size: 1200 } },
],
},
]
async function loadChildren(node: TreeNode<FileNode>): Promise<TreeNode<FileNode>[]> {
// вызывается только при первом раскрытии узла с hasChildren: true
const children = await api.getChildren(node.id)
return children
}
vue
<VirtualTree
:nodes="nodes"
:indent="20"
:on-load-children="loadChildren"
style="height: 400px"
@node-click="(node, depth) => openFile(node)"
>
<template #default="{ row }">
<span>{{ row.node.data.type === 'folder' ? '📁' : '📄' }}</span>
<span>{{ row.node.data.name }}</span>
<span v-if="row.isLoading" class="spinner" />
</template>
</VirtualTree>
Состояние раскрытых узлов хранится внутри компонента. treeRef.value?.expandAll() и collapseAll() управляют всем деревом программно. expandedIds — Readonly<Ref<Set<...>>> для доступа к текущему состоянию.
InfiniteLoader — бесконечная прокрутка
Обёртка над VirtualList которая вызывает onLoadMore когда пользователь достигает threshold пикселей до края списка. Поддерживает загрузку снизу, сверху (лента сообщений) и в обоих направлениях.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { InfiniteLoader } from 'vue-virtual-scroller-kit'
const messages = ref<Message[]>([])
const isLoading = ref(false)
const hasMore = ref(true)
let cursor: string | null = null
async function loadMore() {
isLoading.value = true
try {
const res = await api.getMessages({ cursor, limit: 50 })
// prepend — новые сообщения добавляются сверху
messages.value = [...res.items, ...messages.value]
cursor = res.nextCursor
hasMore.value = res.hasMore
} finally {
isLoading.value = false
}
}
</script>
<template>
<InfiniteLoader
:items="messages"
:on-load-more="loadMore"
:is-loading="isLoading"
:has-more="hasMore"
direction="up"
:threshold="300"
:estimated-item-size="64"
style="height: 100%"
>
<template #default="{ item }">
<MessageRow :message="item" />
</template>
<template #loading-indicator>
<div class="loading-dots">Загрузка...</div>
</template>
</InfiniteLoader>
</template>
При direction="up" компонент сохраняет позицию прокрутки после добавления новых элементов в начало — scrollTop корректируется на разницу высот чтобы viewport не прыгал.
VirtualSelect — виртуализированный dropdown
Select для ситуаций когда опций тысячи. Справочник городов, список пользователей, каталог товаров — стандартный <select> или даже большинство UI-библиотечных select'ов с таким количеством опций начинают тормозить при открытии. Здесь в DOM всегда только видимые строки.
vue
<script setup lang="ts">
import { VirtualSelect } from 'vue-virtual-scroller-kit'
interface Country { code: string; name: string; flag: string }
// 250 стран — все в dropdown без тормозов
const countries: Country[] = allCountries
const selected = ref<Country | null>(null)
</script>
<template>
<VirtualSelect
v-model="selected"
:options="countries"
label-field="name"
value-field="code"
clearable
style="width: 300px"
@search="onSearch"
>
<template #default="{ option }">
<span>{{ option.flag }} {{ option.name }}</span>
</template>
</VirtualSelect>
</template>
Поиск работает на стороне клиента по умолчанию — фильтрует по labelField. Если нужен серверный поиск — слушай событие search и обновляй массив options извне. Компонент поддерживает полную клавиатурную навигацию: ↑↓ по опциям, Enter для выбора, Escape для закрытия.
useVirtualKeyboardNav — клавиатурная навигация
Composable который добавляет стрелочную навигацию к любому виртуальному списку. Подключается отдельно от компонента — можно использовать с кастомным рендерингом.
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(largeList)
const listRef = ref<VirtualListExpose | null>(null)
const selectedId = ref<number | null>(null)
const { focusedIndex, setFocus, isFocused } = useVirtualKeyboardNav({
itemCount: computed(() => items.value.length),
scrollTo: (i, align) => listRef.value?.scrollTo(i, align),
onActivate: (i) => { selectedId.value = items.value[i].id },
loop: false,
})
</script>
<template>
<div tabindex="0" style="outline: none" @focus="setFocus(0)">
<VirtualList ref="listRef" :items="items" :estimated-item-size="48" style="height: 400px">
<template #default="{ item, index }">
<div
class="list-item"
:class="{
'list-item--focused': isFocused(index),
'list-item--selected': item.id === selectedId,
}"
role="option"
:aria-selected="isFocused(index)"
@click="setFocus(index)"
>
{{ item.label }}
</div>
</template>
</VirtualList>
</div>
</template>
Обрабатываются клавиши: ↑↓ — перемещение на одну позицию, Home/End — первый/последний элемент, PageUp/PageDown — прыжок на ±10, Enter/Space — активация. При loop: true — навигация закольцовывается на границах.
useDraggableList — перетаскивание с анимацией
Composable для drag-to-reorder. Работает на pointer events — без HTML5 Drag API, который плохо контролируется и не поддерживает тач-устройства одинаково во всех браузерах. При перетаскивании показывает призрак элемента следующего за курсором, соседние элементы анимированно расступаются через translateY, список автоматически скроллится когда тащишь к краям.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useDraggableList } from 'vue-virtual-scroller-kit'
interface Lane { id: number; title: string; color: string }
const lanes = ref<Lane[]>([
{ id: 1, title: 'Бэклог', color: '#6366f1' },
{ id: 2, title: 'В работе', color: '#22c55e' },
{ id: 3, title: 'Готово', color: '#f97316' },
])
const containerRef = ref<HTMLElement | null>(null)
const { isDragging, dragIndex, ghostStyle, getItemStyle, getItemProps } = useDraggableList({
items: lanes,
scrollContainer: containerRef,
onReorder: (newItems, from, to) => {
lanes.value = newItems
saveOrder(newItems.map((l) => l.id))
},
isDragDisabled: (item) => item.title === 'Готово', // эту колонку нельзя двигать
})
</script>
<template>
<div ref="containerRef" class="kanban-columns">
<div
v-for="(lane, index) in lanes"
:key="lane.id"
v-bind="getItemProps(index)"
class="kanban-column"
:style="[{ borderTopColor: lane.color }, getItemStyle(index)]"
>
<div class="column-header">
<span class="drag-handle">⣿</span>
{{ lane.title }}
</div>
<!-- карточки задач -->
</div>
</div>
<!-- призрак элемента — телепортируется в body чтобы не обрезался overflow -->
<Teleport to="body">
<div
v-if="isDragging && dragIndex >= 0"
class="kanban-column kanban-column--ghost"
:style="[{ borderTopColor: lanes[dragIndex]?.color }, ghostStyle]"
>
{{ lanes[dragIndex]?.title }}
</div>
</Teleport>
</template>
getItemProps(index) возвращает объект для v-bind: data-drag-index для хит-тестирования, onPointerdown для начала drag, CSS-классы vvsk-drag--dragging (для placeholder) и vvsk-drag--over (для целевой позиции). getItemStyle(index) возвращает стили анимации: opacity: 0 для placeholder, translateY(±height) для соседей которые должны сдвинуться.
Автоскролл срабатывает когда курсор входит в зону 60px от края контейнера. Скорость пропорциональна расстоянию — не рывками, а плавно.
useVirtualScroll — низкоуровневый composable
Для случаев когда нужна своя разметка контейнера — без компонентов.
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) => ({ id: i, text: `Элемент ${i + 1}` }))
const containerRef = ref<HTMLElement | null>(null)
const { visibleRange, totalHeight, offsetTop, scrollTo } = 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].text }}
</div>
</div>
</div>
</template>
Полезно когда нужна нестандартная виртуализация — например, горизонтальный скроллер, холст с пользовательскими хитростями, или интеграция с другим UI-слоем.
SSR и гидрация без сдвигов
На сервере ResizeObserver и requestAnimationFrame недоступны. Компонент определяет окружение через typeof window === 'undefined' и в SSR-режиме рендерит первые ssrPreloadCount строк (по умолчанию 20) с оценочными высотами.
vue
<VirtualList
:items="posts"
:estimated-item-size="96"
:ssr-preload-count="30"
style="height: 100%"
>
<template #default="{ item }"><PostCard :post="item" /></template>
</VirtualList>
На сервере в DOM попадают 30 строк с position: absolute и предсказанными смещениями — контент виден без JavaScript. После гидрации ResizeObserver начинает измерять реальные высоты, позиции пересчитываются через PositionManager. Если оценка была точной — прыжков нет. Если нет — строки сдвигаются в правильные позиции тихо, без визуального скачка скролла.
Сценарии в реальных проектах
CRM: таблица сделок с фильтрами
В CRM-системах таблицы сделок могут содержать тысячи записей, десятки колонок, sticky-итоги внизу и возможность переупорядочивать колонки. Стандартный подход — пагинация на 20–50 элементов. Виртуализация позволяет убрать пагинацию вообще и работать со всеми данными сразу.
ts
const columns: ColumnDef[] = [
{ key: 'id', title: '№', width: 60, fixed: 'left' },
{ key: 'client', title: 'Клиент', width: 200 },
{ key: 'manager', title: 'Менеджер', width: 160 },
{ key: 'amount', title: 'Сумма', width: 120 },
{ key: 'stage', title: 'Этап', width: 140 },
{ key: 'created', title: 'Создана', width: 120 },
{ key: 'deadline', title: 'Дедлайн', width: 120 },
{ key: 'tags', title: 'Теги', minWidth: 200 },
{ key: 'actions', title: '', width: 100, fixed: 'right' },
]
// итоговая строка — сумма по всем сделкам в выборке
const totalsRow = computed(() => ({
id: 'total',
client: `Итого: ${filteredDeals.value.length} сделок`,
amount: filteredDeals.value.reduce((s, d) => s + d.amount, 0),
}))
vue
<VirtualTable
:columns="columns"
:rows="filteredDeals"
:pinned-bottom-rows="[totalsRow]"
sortable
multi-sort
resizable-columns
:estimated-item-size="48"
style="height: calc(100vh - 180px)"
@sort-change="handleSort"
@column-resize="saveColumnWidth"
>
<template #cell="{ row, column, value }">
<DealAmount v-if="column.key === 'amount'" :value="value" />
<StageTag v-else-if="column.key === 'stage'" :stage="value" />
<TagsList v-else-if="column.key === 'tags'" :tags="value" />
<DealActions v-else-if="column.key === 'actions'" :deal="row" />
<span v-else>{{ value }}</span>
</template>
</VirtualTable>
Итоговая строка с суммами закреплена снизу и всегда видна независимо от позиции скролла. Мультисортировка через Shift+Click позволяет, например, отсортировать по этапу, а внутри этапа — по сумме. Ширины колонок сохраняются в localStorage через column-resize.
Мессенджер: история сообщений
Лента сообщений — классический кейс для виртуализации с двунаправленной подгрузкой. Новые сообщения появляются снизу по WebSocket. История подгружается снизу вверх при скролле к началу ленты.
ts
const { isDragging: _, ...draggable } = useDraggableList({ items: messages, ... }) // не нужен здесь
// WebSocket: новые сообщения добавляем в конец
socket.on('message', (msg: Message) => {
messages.value = [...messages.value, msg]
// автоскролл вниз только если пользователь уже в конце
if (isAtBottom.value) {
nextTick(() => loaderRef.value?.scrollToOffset(Infinity))
}
})
vue
<InfiniteLoader
ref="loaderRef"
:items="messages"
:on-load-more="loadHistory"
:is-loading="isLoadingHistory"
:has-more="hasMoreHistory"
direction="up"
:threshold="400"
:estimated-item-size="(item) => item.type === 'image' ? 280 : item.text.length > 200 ? 120 : 64"
style="height: 100%"
>
<template #default="{ item }">
<MessageBubble
:message="item"
:is-own="item.authorId === currentUserId"
/>
</template>
</InfiniteLoader>
estimated-item-size как функция даёт хорошую начальную оценку без измерения: короткие сообщения — 64px, длинные — 120px, сообщения с картинкой — 280px. После рендера ResizeObserver уточняет реальные высоты. Подгрузка истории восстанавливает позицию скролла — пользователь не теряет место в ленте.
Файловый менеджер: дерево с drag-and-drop
Файловый менеджер с деревом каталогов слева и содержимым папки в виде сетки справа. Дерево — ленивое, каталоги подгружаются при первом раскрытии. Файлы в сетке можно переупорядочивать перетаскиванием.
ts
// левая панель — дерево
async function loadChildren(node: TreeNode<FsNode>): Promise<TreeNode<FsNode>[]> {
const children = await fs.list(node.id)
return children.map((c) => ({
id: c.id,
data: { name: c.name, type: c.type, size: c.size },
hasChildren: c.type === 'dir',
}))
}
// правая панель — сетка с drag-to-reorder
const { getItemProps, getItemStyle, ghostStyle, isDragging, dragIndex } = useDraggableList({
items: currentFiles,
scrollContainer: gridRef,
onReorder: async (newItems, from, to) => {
currentFiles.value = newItems
await fs.reorder(currentFolderId.value, newItems.map((f) => f.id))
},
})
vue
<div class="file-manager">
<!-- левая панель: дерево каталогов -->
<VirtualTree
:nodes="rootNodes"
:on-load-children="loadChildren"
:indent="16"
style="width: 240px; height: 100%"
@node-click="openFolder"
>
<template #default="{ row }">
<FolderIcon v-if="row.hasChildren" :loading="row.isLoading" />
<span>{{ row.node.data.name }}</span>
</template>
</VirtualTree>
<!-- правая панель: содержимое папки как сетка -->
<div ref="gridRef" class="file-grid-container">
<VirtualGrid
:items="currentFiles"
:column-width="160"
:row-height="160"
:gap="8"
style="height: 100%"
>
<template #default="{ item, index }">
<div
v-bind="getItemProps(index)"
class="file-card"
:style="getItemStyle(index)"
@dblclick="openFile(item)"
>
<FilePreview :file="item" />
<span class="file-name">{{ item.name }}</span>
</div>
</template>
</VirtualGrid>
<Teleport to="body">
<div
v-if="isDragging && dragIndex >= 0"
class="file-card file-card--ghost"
:style="ghostStyle"
>
<FilePreview :file="currentFiles[dragIndex]" />
<span>{{ currentFiles[dragIndex]?.name }}</span>
</div>
</Teleport>
</div>
</div>
Дерево каталогов — полностью ленивое, начальная загрузка минимальна. Сетка файлов виртуализирована — папка с тысячами файлов открывается мгновенно. Drag-and-drop работает через pointer events, поэтому работает на тач-устройствах без дополнительных усилий.
NPM: https://www.npmjs.com/package/vue-virtual-scroller-kit
GitHub: https://github.com/macrulezru/vue-virtual-scroller-kit