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

23.05.2026
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 Copy
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 Copy
<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 Copy
:estimated-item-size="(item) => item.isExpanded ? 200 : 56"

Scroll restoration

Добавляешь restore-key — список запоминает позицию в sessionStorage и восстанавливает её при повторном монтировании. Полезно в SPA где пользователь открывает детальную страницу и возвращается назад.

vue Copy
<VirtualList :items="items" restore-key="issues-list" :estimated-item-size="72" style="height: 100%" />

DOM recycling

Пропс recycle-pool включает переиспользование DOM-узлов вместо их демонтирования. При прокрутке строка не удаляется и не создаётся заново — она просто получает новые данные через реактивность. Это убирает garbage collector pressure при быстрой прокрутке тяжёлых строк.

vue Copy
<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 Copy
<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 Copy
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 Copy
<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 Copy
<VirtualTable
  :columns="columns"
  :rows="rows"
  :pinned-top-rows="[totalsRow]"
  :pinned-bottom-rows="[grandTotalRow]"
>

Закреплённые строки остаются видимыми при прокрутке — через position: sticky. Рендерятся вне виртуального списка, так что их высота не влияет на виртуализацию.


VirtualGrid — сетка с авто-колонками

Для галерей, карточных интерфейсов, плиточных раскладок. Количество колонок вычисляется автоматически по ширине контейнера и column-width, или задаётся фиксировано.

vue Copy
<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 Copy
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 Copy
<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() управляют всем деревом программно. expandedIdsReadonly<Ref<Set<...>>> для доступа к текущему состоянию.


InfiniteLoader — бесконечная прокрутка

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

vue Copy
<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 не прыгал.


Select для ситуаций когда опций тысячи. Справочник городов, список пользователей, каталог товаров — стандартный <select> или даже большинство UI-библиотечных select'ов с таким количеством опций начинают тормозить при открытии. Здесь в DOM всегда только видимые строки.

vue Copy
<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 Copy
<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 Copy
<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 Copy
<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 Copy
<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 Copy
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 Copy
<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 Copy
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 Copy
<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 Copy
// левая панель — дерево
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 Copy
<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

Читать далее

23.05.2026

Command Palette для Vue 3 — fuzzy-поиск, вложенные палитры и глобальные хоткеи из коробки

@macrulez/vue-command-palette — готовый Command+K интерфейс для Vue 3 с fuzzy-поиском, группами, вложенными палитрами, историей команд и полной поддержкой тем. Единственная peer-зависимость — Vue 3.

Метки
vuevue3command-palettetypescriptopen-source
24.05.2026

vue-storage-kit — реактивное хранилище для Vue 3 с шифрованием, миграциями и синхронизацией вкладок

Пакет, который закрывает всё что нужно для работы с localStorage, sessionStorage, IndexedDB и cookies во Vue 3 — TTL, AES-GCM шифрование, схема-миграции, кросс-вкладочная синхронизация и Pinia-персист, без единой лишней зависимости.

Метки
vue3typescriptlocalstorageindexeddbpinia