npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vue-virtual-scroller-kit

v0.1.7

Published

Virtual list and table with dynamic row heights, grouping, sticky headers and SSR support for Vue 3

Readme

Virtual list, table, grid, tree, and select for Vue 3. Dynamic row heights measured by ResizeObserver, grouping with animated expand/collapse, sticky headers, infinite scroll, keyboard navigation, drag-to-reorder, and full SSR support — all with a single peer dependency (Vue 3).


Contents


Features

  • Dynamic row heightsResizeObserver measures each row after render; position manager updates in O(log n)
  • VirtualList — flat list, external scroll container, page-mode (window scroll), DOM recycling pool, scroll restoration
  • GroupedVirtualList — collapsible sections with smooth expand/collapse CSS animation
  • VirtualTable — native <table> with spacer-row virtual scroll, sticky header, fixed left/right columns, single and multi-column sort, drag-to-resize columns, column virtualization, pinned top/bottom rows (<thead>/<tfoot>), built-in lazy loading (onLoadMore / hasMore / isLoading)
  • VirtualGrid — fixed-height cells in a responsive auto-column or fixed-column grid
  • VirtualTree — hierarchical expand/collapse with lazy child loading, configurable indent
  • InfiniteLoader — trigger onLoadMore near the bottom, top, or both ends; scroll-position preservation when prepending
  • VirtualSelect — virtualized dropdown with client-side search and keyboard navigation
  • useVirtualScroll — raw composable for custom containers; returns visibleRange, totalHeight, scrollTo
  • useVirtualKeyboardNav — arrow keys, Home/End, PageUp/PageDown, Enter/Space; plugs into any virtual list
  • useDraggableList — pointer-event drag-to-reorder with animated gap, ghost element, auto-scroll, disabled-item support
  • PositionManager — segment tree (O(log n) updates and prefix-sum queries) exposed for advanced use
  • autoColWidths — estimates column widths from a data sample via Canvas measureText; SSR-safe fallback
  • SSR-safe — first N rows rendered on the server, hydration without layout shift
  • Zero external dependencies — only Vue 3 as peer dep
  • Tree-shakeable ESM + CJS dual build

Demo

npm install
npm run demo

Opens at http://localhost:5174:

| Page | What it shows | |---|---| | VirtualList | Flat list, 10 000 items, mixed row heights | | GroupedVirtualList | Collapsible groups, expand/collapse all | | VirtualTable | Sortable, resizable, fixed columns, pinned rows | | VirtualGrid | Auto-column responsive grid | | VirtualTree | Nested hierarchy, lazy child loading | | InfiniteLoader | Bidirectional infinite scroll | | VirtualSelect | Virtualized searchable dropdown | | Composables | Keyboard navigation + drag-to-reorder |


Installation

npm install vue-virtual-scroller-kit

Peer dependency:

npm install vue@>=3.3

Quick start

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

interface Row { id: number; text: string }

const items: Row[] = Array.from({ length: 100_000 }, (_, i) => ({
  id: i,
  text: `Row ${i + 1}`,
}))
</script>

<template>
  <VirtualList :items="items" :estimated-item-size="48" style="height: 600px">
    <template #default="{ item, index }">
      <div style="padding: 12px 16px; border-bottom: 1px solid #eee">
        {{ index + 1 }}. {{ item.text }}
      </div>
    </template>
  </VirtualList>
</template>

VirtualList

The core component. Renders only the rows visible in the viewport plus an overscan buffer. ResizeObserver measures each row after mount so variable-height rows are handled automatically.

Props

| Prop | Type | Default | Description | |---|---|---|---| | items | T[] | — | Data array | | keyField | string | 'id' | Field used as the :key for each row | | estimatedItemSize | number \| (item, index) => number | 50 | Initial height estimate per row | | overscan | number | 3 | Extra rows rendered above/below viewport | | minHeight | number | 0 | Minimum total list height in px | | scrollElement | HTMLElement \| null | null | External scroll container (mutually exclusive with pageMode) | | pageMode | boolean | false | Use window as the scroll container | | isLoading | boolean | false | Shows the #skeleton slot when items is empty | | restoreKey | string | — | Key used to save/restore scroll position in sessionStorage | | ssrPreloadCount | number | 20 | Number of rows rendered on the server | | recyclePool | boolean | false | Reuse DOM nodes instead of unmounting them (better scroll FPS, disables key-based transitions) |

Slots

| Slot | Scope | Description | |---|---|---| | #default | { item: T, index: number, style } | Row content | | #empty | — | Rendered when items is empty and not loading | | #skeleton | — | Rendered when items is empty and isLoading is true | | #loading | — | Rendered at the bottom while isLoading is true |

Emits

| Event | Payload | Description | |---|---|---| | scroll | Event | Native scroll event | | visible-range-change | { start: number; end: number } | Fires when the visible slice changes |

Exposed API (VirtualListExpose)

import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

const listRef = ref<VirtualListExpose | null>(null)

listRef.value?.scrollTo(index, align)     // 'start' | 'center' | 'end' | 'auto'
listRef.value?.scrollToOffset(px)         // scroll to a pixel offset
listRef.value?.measureItem(index, height) // manually set a row height

Examples

Fixed-height rows:

<VirtualList :items="rows" :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }">
    <div class="row">{{ item.name }}</div>
  </template>
</VirtualList>

Variable-height rows (per-item estimate):

<VirtualList
  :items="posts"
  :estimated-item-size="(item) => item.isExpanded ? 200 : 60"
  style="height: 600px"
>
  <template #default="{ item }">
    <PostCard :post="item" />
  </template>
</VirtualList>

External scroll container:

<div ref="scrollEl" style="overflow-y: auto; height: 400px">
  <VirtualList :items="rows" :scroll-element="scrollEl" :estimated-item-size="48">
    <template #default="{ item }"><Row :data="item" /></template>
  </VirtualList>
</div>

Page-mode (whole page scrolls):

<VirtualList :items="rows" page-mode :estimated-item-size="80">
  <template #default="{ item }"><Article :post="item" /></template>
</VirtualList>

Skeleton loading state:

<VirtualList :items="items" :is-loading="isLoading" :estimated-item-size="56" style="height: 500px">
  <template #default="{ item }"><Item :data="item" /></template>
  <template #skeleton>
    <SkeletonRow v-for="i in 10" :key="i" />
  </template>
  <template #empty>
    <div>No items found.</div>
  </template>
</VirtualList>

Scroll restoration:

<VirtualList :items="rows" restore-key="my-list" :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }"><Row :data="item" /></template>
</VirtualList>

DOM recycling pool (high-FPS heavy rows):

<VirtualList :items="rows" recycle-pool :estimated-item-size="80" style="height: 500px">
  <template #default="{ item }"><HeavyRow :data="item" /></template>
</VirtualList>

Note: recyclePool reuses DOM nodes so keyed Vue transitions on individual items won't work. Use it when render performance matters more than per-item animations.


GroupedVirtualList

Renders items grouped under collapsible section headers. Each group can be expanded or collapsed with a smooth CSS animation.

Props

| Prop | Type | Default | Description | |---|---|---|---| | groups | GroupDef<T>[] | — | Array of group definitions | | estimatedItemSize | number | 50 | Estimated height of item rows | | estimatedGroupHeaderSize | number | 40 | Estimated height of group header rows | | overscan | number | 3 | Extra rows rendered outside viewport | | keyField | string | 'id' | Field used as the item key |

GroupDef<T>

interface GroupDef<T> {
  key: string      // unique identifier for the group
  label: string    // display label
  items: T[]       // items in this group
  collapsed?: boolean // initial collapsed state
}

Slots

| Slot | Scope | Description | |---|---|---| | #group-header | { group: GroupDef<T>, toggle: () => void, isCollapsed: boolean } | Custom group header | | #default | { item: T, index: number, groupKey: string } | Item row content | | #empty | — | Shown when all groups are empty |

Emits

Same as VirtualList: scroll, visible-range-change.

Exposed API (GroupedVirtualListExpose)

import type { GroupedVirtualListExpose } from 'vue-virtual-scroller-kit'

const listRef = ref<GroupedVirtualListExpose | null>(null)

listRef.value?.toggle('group-key')  // toggle a group open/closed
listRef.value?.scrollTo(index)      // scroll to a flat row index

Example

<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 Contact { id: number; name: string; email: string }

const groups = ref<GroupDef<Contact>[]>([
  {
    key: 'a',
    label: 'A',
    items: [
      { id: 1, name: 'Alice', email: '[email protected]' },
      { id: 2, name: 'Aaron', email: '[email protected]' },
    ],
  },
  {
    key: 'b',
    label: 'B',
    items: [{ id: 3, name: 'Bob', email: '[email protected]' }],
    collapsed: true,
  },
])

const listRef = ref<GroupedVirtualListExpose | null>(null)

function expandAll() {
  groups.value = groups.value.map((g) => ({ ...g, collapsed: false }))
}
function collapseAll() {
  groups.value = groups.value.map((g) => ({ ...g, collapsed: true }))
}
</script>

<template>
  <button @click="expandAll">Expand all</button>
  <button @click="collapseAll">Collapse all</button>

  <GroupedVirtualList
    ref="listRef"
    :groups="groups"
    :estimated-item-size="56"
    style="height: 500px"
  >
    <template #group-header="{ group, toggle, isCollapsed }">
      <div class="group-header" @click="toggle">
        {{ isCollapsed ? '▶' : '▼' }} {{ group.label }}
        <span>({{ group.items.length }})</span>
      </div>
    </template>

    <template #default="{ item }">
      <div class="contact-row">
        <strong>{{ item.name }}</strong>
        <span>{{ item.email }}</span>
      </div>
    </template>
  </GroupedVirtualList>
</template>

VirtualTable

A virtual table rendered as a native <table> element. The component uses <thead> / <tbody> / <tfoot> with two spacer rows (top and bottom) to create the virtual scroll effect while keeping the browser's built-in column width synchronisation between header and body. Row heights are measured by ResizeObserver after each render, so rows can have arbitrary content.

Features: sticky header, fixed left/right columns, single/multi-column sort with sort-stack indicator, optional drag-to-resize columns, horizontal column virtualization, pinned top/bottom rows, and built-in infinite scroll (onLoadMore).

Architecture

<div class="vvsk-table">          ← scroll container (overflow: auto)
  <table>
    <colgroup>                    ← column widths, auto-syncs header ↔ body
    <thead>                       ← position: sticky top; contains header row
      <tr> … <th> …               ← column headers (sort on click)
      <tr> … <td> …               ← pinnedTopRows (always visible, sticky)
    <tbody>
      <tr class="spacer">         ← top virtual space (height = offsetTop)
      <tr v-for visibleRows>      ← only rendered rows
      <tr class="spacer">         ← bottom virtual space
    <tfoot>                       ← position: sticky bottom; pinnedBottomRows

Props

| Prop | Type | Default | Description | |---|---|---|---| | columns | ColumnDef[] | — | Column definitions | | rows | T[] | — | Data rows | | estimatedItemSize | number | 40 | Estimated row height used before measurement | | stickyHeader | boolean | true | Pin the header row to the top | | stickyHeaderOffset | number | 0 | Top offset for the sticky header (e.g. navbar height) | | sortable | boolean | false | Enable single-column sort on header click | | multiSort | boolean | false | Enable Shift+click multi-column sort | | virtualizeColumns | boolean | false | Render only horizontally visible columns (for 50+ columns) | | resizableColumns | boolean | false | Allow drag-to-resize column borders | | pinnedTopRows | T[] | [] | Rows pinned inside <thead>, always visible at the top | | pinnedBottomRows | T[] | [] | Rows pinned inside <tfoot>, always visible at the bottom | | overscan | number | 3 | Extra rows rendered outside the viewport | | keyField | string | 'id' | Row key field (must be unique per row) | | onLoadMore | () => void | — | Called when user scrolls near the bottom and hasMore is true | | hasMore | boolean | false | Whether more rows are available to load | | isLoading | boolean | false | Whether a load is in progress (prevents duplicate calls) | | loadMoreThreshold | number | 150 | Distance from the bottom edge in px that triggers onLoadMore | | uniformRowHeight | boolean | false | All rows have identical height — disables ResizeObserver to prevent scroll drift. Set estimatedItemSize to the exact row height |

ColumnDef

interface ColumnDef {
  key: string                    // matches the row object property
  title: string                  // header label
  width?: number                 // column width in px (fallback: minWidth ?? 100)
  minWidth?: number              // minimum width after drag-resize
  maxWidth?: number              // maximum width after drag-resize
  fixed?: 'left' | 'right'      // sticky fixed column
}

Slots

| Slot | Scope | Description | |---|---|---| | #header-cell | { column: ColumnDef } | Custom header cell content. Default renders title + sort arrow (↑/↓) for the active sort column only | | #row | { row: T, index: number } | Replace entire <tr> rendering | | #cell | { row: T, column: ColumnDef, value: unknown } | Custom cell content | | #pinned-row | { row: T, index: number, position: 'top' \| 'bottom' } | Replace entire pinned <tr> | | #pinned-cell | { row: T, column: ColumnDef, value: unknown, position: 'top' \| 'bottom' } | Custom pinned cell content | | #loading-indicator | — | Shown below the table when isLoading && hasMore |

Emits

| Event | Payload | Description | |---|---|---| | sort-change | SortChange \| SortChange[] | Single sort object (sortable), or array (multiSort) | | column-resize | [key: string, width: number] | Fires after a column drag-resize ends | | scroll | Event | Native scroll event from the container | | visible-range-change | { start: number; end: number } | Fires on scroll with the current visible row indices |

SortChange

interface SortChange {
  key: string
  direction: 'asc' | 'desc' | null
}

Exposed API

import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

const tableRef = ref<VirtualListExpose & {
  getSortStack: () => SortChange[]
  clearSort: () => void
} | null>(null)

tableRef.value?.scrollTo(rowIndex, 'start')   // 'start' | 'center' | 'end' | 'auto'
tableRef.value?.scrollToOffset(px)
tableRef.value?.clearSort()
tableRef.value?.getSortStack()                 // current sort state

CSS custom property

Fixed columns and pinned rows use --vvsk-sticky-bg for their cell background to cover scrolling content behind them. Set it on the table element to match your theme:

.my-table { --vvsk-sticky-bg: var(--surface-color); }

Default fallback is #fff.

Examples

Basic — sort, fixed columns, custom cells, resizable columns:

<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'

interface User { id: number; name: string; email: string; age: number }

const originalRows: User[] = [
  { id: 1, name: 'Alice', email: '[email protected]', age: 28 },
  { id: 2, name: 'Bob',   email: '[email protected]',   age: 35 },
  // …
]

const columns: ColumnDef[] = [
  { key: 'id',    title: '#',     width: 60,  fixed: 'left' },
  { key: 'name',  title: 'Name',  width: 180 },
  { key: 'email', title: 'Email', minWidth: 200 },
  { key: 'age',   title: 'Age',   width: 80 },
]

const rows = ref<User[]>([...originalRows])

function onSort(sort: SortChange | SortChange[]) {
  const s = Array.isArray(sort) ? sort[0] : sort
  if (!s || !s.direction) { rows.value = [...originalRows]; return }
  rows.value = [...rows.value].sort((a, b) =>
    s.direction === 'asc'
      ? String(a[s.key as keyof User]).localeCompare(String(b[s.key as keyof User]))
      : String(b[s.key as keyof User]).localeCompare(String(a[s.key as keyof User])),
  )
}
</script>

<template>
  <VirtualTable
    :columns="columns"
    :rows="rows"
    key-field="id"
    sortable
    resizable-columns
    style="height: 500px"
    @sort-change="onSort"
  >
    <template #cell="{ column, value }">
      <span v-if="column.key === 'age'" :style="{ color: (value as number) < 30 ? 'green' : 'inherit' }">
        {{ value }}
      </span>
      <span v-else>{{ value }}</span>
    </template>
  </VirtualTable>
</template>

Multi-column sort (Shift+click to add columns to sort stack):

<VirtualTable
  :columns="columns"
  :rows="rows"
  multi-sort
  style="height: 500px"
  @sort-change="onMultiSort"
/>
function onMultiSort(sort: SortChange | SortChange[]) {
  const stack = Array.isArray(sort) ? sort : [sort]
  rows.value = [...rows.value].sort((a, b) => {
    for (const { key, direction } of stack) {
      if (!direction) continue
      const cmp = String(a[key as keyof Row]).localeCompare(String(b[key as keyof Row]))
      if (cmp !== 0) return direction === 'asc' ? cmp : -cmp
    }
    return 0
  })
}

Auto column widths — measure content with Canvas before rendering:

import { autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'

const rawCols = [
  { key: 'id',    title: 'ID' },
  { key: 'name',  title: 'Name' },
  { key: 'email', title: 'Email' },
]

// Call after rows are loaded
const widths = autoColWidths(rawCols, rows, {
  font: '12px Inter, sans-serif',
  padding: 24,
  maxWidth: 400,
})

const columns: ColumnDef[] = rawCols.map(c => ({
  key: c.key,
  title: c.title,
  width: widths.get(c.key) ?? 120,
  minWidth: 60,
}))

Pinned rows — rows that stay visible while the body scrolls. Top rows live in <thead> (sticky to top), bottom rows live in <tfoot> (sticky to bottom):

<script setup lang="ts">
const pinnedTop    = [{ id: -1, name: '📌 Pinned', score: 0 }]
const pinnedBottom = [{ id: -2, name: '∑ Total',  score: totalScore }]
</script>

<template>
  <VirtualTable
    :columns="columns"
    :rows="rows"
    :pinned-top-rows="pinnedTop"
    :pinned-bottom-rows="pinnedBottom"
    style="height: 500px; --vvsk-sticky-bg: #fff"
  >
    <template #pinned-cell="{ row, column, position }">
      <strong v-if="position === 'bottom'">{{ row[column.key] }}</strong>
      <span v-else>{{ row[column.key] }}</span>
    </template>
  </VirtualTable>
</template>

Pinned cells automatically receive background-color: var(--vvsk-sticky-bg, #fff) so they always cover the scrolling rows behind them.


Lazy loading — infinite scroll triggered when near the bottom:

<script setup lang="ts">
import { ref, computed } from 'vue'
import { VirtualTable, autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'

interface Row { id: number; name: string; email: string }

const PAGE = 100
const rows      = ref<Row[]>([])
const total     = ref(0)
const loading   = ref(false)
const hasMore   = computed(() => rows.value.length < total.value)

// Columns sized from data after first load
const colWidths = ref<Map<string, number>>(new Map())
const rawCols   = [{ key: 'id', title: '#' }, { key: 'name', title: 'Name' }, { key: 'email', title: 'Email' }]
const columns   = computed((): ColumnDef[] => [
  ...rawCols.map(c => ({ key: c.key, title: c.title, width: colWidths.value.get(c.key) ?? 120, minWidth: 60 })),
  { key: '__actions', title: '', width: 80, fixed: 'right' as const },
])

async function fetchRows(page: number, replace: boolean) {
  if (loading.value) return
  loading.value = true
  try {
    const res  = await fetch(`/api/rows?page=${page}&limit=${PAGE}`)
    const data = await res.json()
    rows.value  = replace ? data.rows : [...rows.value, ...data.rows]
    total.value = data.total
    if (replace) colWidths.value = autoColWidths(rawCols, rows.value, { font: '12px Inter, sans-serif' })
  } finally {
    loading.value = false
  }
}

function loadMore() { fetchRows(Math.floor(rows.value.length / PAGE) + 1, false) }

function onSort(sort: SortChange | SortChange[]) {
  rows.value = []
  fetchRows(1, true)
}

fetchRows(1, true)
</script>

<template>
  <VirtualTable
    :columns="columns"
    :rows="rows"
    key-field="id"
    sortable
    resizable-columns
    :on-load-more="loadMore"
    :has-more="hasMore"
    :is-loading="loading"
    :load-more-threshold="200"
    style="height: 600px; --vvsk-sticky-bg: #fff"
    @sort-change="onSort"
  >
    <template #cell="{ row, column }">
      <template v-if="column.key === '__actions'">
        <button @click="edit(row)">✏</button>
        <button @click="remove(row)">✕</button>
      </template>
      <span v-else>{{ row[column.key as keyof Row] }}</span>
    </template>
    <template #loading-indicator>
      <div style="padding: 12px; text-align: center; opacity: 0.5">Loading…</div>
    </template>
  </VirtualTable>
</template>

Column virtualization — for very wide tables (100+ columns), render only visible columns:

<VirtualTable
  :columns="columns"
  :rows="rows"
  :virtualize-columns="true"
  style="height: 500px"
/>

VirtualGrid

A virtual grid that arranges items in rows and columns. Column count can be fixed or auto-calculated from columnWidth and the container width.

Props

| Prop | Type | Default | Description | |---|---|---|---| | items | T[] | — | Data array | | columns | number | 0 | Fixed column count. Pass 0 to auto-compute from columnWidth | | columnWidth | number | 200 | Cell width for auto-column calculation | | rowHeight | number | 200 | Fixed cell height in px | | gap | number | 8 | Gap between cells in px | | keyField | string | 'id' | Key field | | overscan | number | 2 | Extra rows outside the viewport | | isLoading | boolean | false | Shows skeleton when items is empty |

Slots

| Slot | Scope | Description | |---|---|---| | #default | { item: T, index: number, row: number, col: number } | Cell content | | #empty | — | Shown when items is empty | | #skeleton | — | Shown when empty and isLoading |

Emits

scroll, visible-range-change.

Example

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

interface Photo { id: number; url: string; title: string }

const photos: Photo[] = Array.from({ length: 10_000 }, (_, i) => ({
  id: i,
  url: `https://picsum.photos/seed/${i}/200/200`,
  title: `Photo ${i + 1}`,
}))
</script>

<template>
  <VirtualGrid
    :items="photos"
    :column-width="220"
    :row-height="220"
    :gap="12"
    style="height: 600px"
  >
    <template #default="{ item }">
      <div class="photo-card">
        <img :src="item.url" :alt="item.title" />
        <p>{{ item.title }}</p>
      </div>
    </template>
  </VirtualGrid>
</template>

VirtualTree

A tree view with expand/collapse, configurable indent per depth level, and optional lazy (async) child loading.

Props

| Prop | Type | Default | Description | |---|---|---|---| | nodes | TreeNode<T>[] | — | Root nodes | | indent | number | 20 | Pixel indent per depth level | | estimatedItemSize | number | 36 | Estimated row height | | overscan | number | 5 | Extra rows outside the viewport | | onLoadChildren | (node) => Promise<TreeNode<T>[]> | — | Called when a node with hasChildren: true is first expanded |

TreeNode<T>

interface TreeNode<T extends Record<string, unknown> = Record<string, unknown>> {
  id: string | number
  data: T
  children?: TreeNode<T>[]
  hasChildren?: boolean   // true = has children that haven't been loaded yet
}

FlatTreeRow<T>

The slot scope exposes:

interface FlatTreeRow<T> {
  node: TreeNode<T>
  depth: number
  isExpanded: boolean
  hasChildren: boolean
  isLoading: boolean      // true while onLoadChildren is running
}

Slots

| Slot | Scope | Description | |---|---|---| | #default | { row: FlatTreeRow<T>, index: number } | Custom row content. The toggle button is rendered by the component. | | #empty | — | Empty state |

Emits

| Event | Payload | |---|---| | node-expand | TreeNode<T> | | node-collapse | TreeNode<T> | | node-click | [TreeNode<T>, depth: number] |

Exposed API

treeRef.value?.expandAll()
treeRef.value?.collapseAll()
treeRef.value?.scrollTo(index)
treeRef.value?.expandedIds  // Readonly<Ref<Set<string | number>>>

Example

<script setup lang="ts">
import { VirtualTree } from 'vue-virtual-scroller-kit'
import type { TreeNode, FlatTreeRow } from 'vue-virtual-scroller-kit'

interface FileNode { name: string; type: 'file' | 'folder' }

const nodes: TreeNode<FileNode>[] = [
  {
    id: 1,
    data: { name: 'src', type: 'folder' },
    children: [
      { id: 2, data: { name: 'main.ts', type: 'file' } },
      { id: 3, data: { name: 'App.vue', type: 'file' } },
    ],
  },
  {
    id: 4,
    data: { name: 'node_modules', type: 'folder' },
    hasChildren: true,   // lazy-loaded
  },
]

async function loadChildren(node: TreeNode<FileNode>): Promise<TreeNode<FileNode>[]> {
  const res = await fetch(`/api/children/${node.id}`)
  return res.json()
}
</script>

<template>
  <VirtualTree
    :nodes="nodes"
    :indent="20"
    :on-load-children="loadChildren"
    style="height: 400px"
    @node-click="(node, depth) => console.log(node.data.name, depth)"
  >
    <template #default="{ row }">
      <span>{{ row.node.data.type === 'folder' ? '📁' : '📄' }} {{ row.node.data.name }}</span>
    </template>
  </VirtualTree>
</template>

InfiniteLoader

Wraps VirtualList and calls onLoadMore when the user scrolls within threshold pixels of the bottom (or top, or both).

Props

| Prop | Type | Default | Description | |---|---|---|---| | items | T[] | — | Current data array | | onLoadMore | () => Promise<void> | — | Called when more data is needed | | isLoading | boolean | — | Whether a load is in progress | | hasMore | boolean | — | Whether more data exists | | threshold | number | 200 | Distance from edge (px) at which to trigger onLoadMore | | direction | 'down' \| 'up' \| 'both' | 'down' | Which edge(s) trigger loading | | estimatedItemSize | number | 50 | Estimated row height | | overscan | number | 3 | Extra rows outside viewport | | keyField | string | 'id' | Row key field |

Slots

| Slot | Scope | Description | |---|---|---| | #default | { item: T, index: number, style } | Row content | | #loading-indicator | — | Custom loading spinner (shown at top/bottom depending on direction) | | #empty | — | Empty state |

Emits

scroll, visible-range-change.

Exposed API

loaderRef.value?.scrollTo(index, align)
loaderRef.value?.scrollToOffset(px)

Example

<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

A searchable select input backed by a virtualized dropdown. Handles hundreds of thousands of options without DOM overhead.

Props

| Prop | Type | Default | Description | |---|---|---|---| | options | T[] | — | Option objects | | modelValue | T \| null | null | Currently selected option | | labelField | string | 'label' | Field to display in the trigger and dropdown | | valueField | string | 'value' | Field used for equality comparison | | placeholder | string | 'Select an option…' | Placeholder text | | disabled | boolean | false | Disable the select | | clearable | boolean | false | Show a clear button when a value is selected | | searchable | boolean | true | Show a search input when the dropdown opens | | estimatedItemSize | number | 36 | Estimated option row height | | maxVisibleRows | number | 8 | Max rows shown before the dropdown scrolls |

Emits

| Event | Payload | |---|---| | update:modelValue | T \| null | | change | T \| null | | search | string |

Slots

| Slot | Scope | Description | |---|---|---| | #default | { option: T, index: number, selected: boolean } | Custom option row | | #empty | — | Shown when filteredOptions is empty |

Exposed API

selectRef.value?.open()
selectRef.value?.close()

Example

<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: '🇩🇪' },
  // … hundreds more
]

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>

useVirtualScroll

The low-level composable that powers all components. Use it when you need to build a custom virtual container.

Options

| Option | Type | Default | Description | |---|---|---|---| | itemCount | number \| Ref<number> | — | Total item count | | estimatedItemSize | SizeProvider \| Ref<SizeProvider> | 50 | Estimated item height: number or (index) => number. A Ref triggers a full rebuild when it changes | | overscan | number | 3 | Extra items rendered outside the viewport | | getScrollElement | () => HTMLElement \| null | — | Returns the scroll container | | pageMode | boolean | false | Use window as the scroll container |

type SizeProvider = number | ((index: number) => number)

Return value

| Property | Type | Description | |---|---|---| | visibleRange | Readonly<Ref<VisibleRange>> | { start, end } — first and last visible item indices | | totalHeight | Readonly<Ref<number>> | Total scrollable height in px | | offsetTop | (index: number) => number | Pixel offset of item at index | | scrollTo | (index, align?) => void | Scroll to item ('start' \| 'center' \| 'end' \| 'auto') | | scrollToOffset | (offset: number) => void | Scroll to a raw pixel offset | | measureItem | (index, height) => void | Report a measured row height | | handleScroll | () => void | Manually trigger a visible-range recalculation |

Example

<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

Keyboard navigation composable. Attaches keydown listeners and manages a focused index. Works with any virtual list component.

Options

| Option | Type | Default | Description | |---|---|---|---| | itemCount | Ref<number> \| number | — | Total item count | | scrollTo | (index, align?) => void | — | Called to scroll the list when focus moves | | target | Ref<HTMLElement \| null> \| HTMLElement | document | Element that receives keyboard events | | onActivate | (index: number) => void | — | Called on Enter or Space | | onChange | (index: number) => void | — | Called when the focused index changes | | loop | boolean | false | Whether to wrap around at boundaries |

Return value

| Property | Type | Description | |---|---|---| | focusedIndex | Readonly<Ref<number>> | Currently focused index, -1 if nothing focused | | setFocus | (index: number) => void | Programmatically set focus | | isFocused | (index: number) => boolean | Whether index is focused |

Keys handled

| Key | Action | |---|---| | | Move focus up | | | Move focus down | | Home | Focus first item | | End | Focus last item | | PageUp | Jump −10 items | | PageDown | Jump +10 items | | Enter / Space | Call onActivate |

Example

<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

Pointer-event drag-to-reorder composable. Shows a fixed-position ghost element that follows the cursor, animates neighbouring items with translateY, and auto-scrolls the container when dragging near the edges.

Options

| Option | Type | Default | Description | |---|---|---|---| | items | Ref<T[]> | — | Reactive items array | | onReorder | (newItems, from, to) => void | — | Called after a successful drop with the reordered array | | isDragDisabled | (item, index) => boolean | — | Return true to prevent an item from being dragged | | scrollContainer | HTMLElement \| Ref<HTMLElement \| null> | — | Container for auto-scroll when dragging near its edges |

Return value

| Property | Type | Description | |---|---|---| | dragIndex | Readonly<Ref<number>> | Index of the item being dragged, -1 when idle | | overIndex | Readonly<Ref<number>> | Index of the current drop target | | isDragging | Readonly<Ref<boolean>> | Whether a drag is in progress | | ghostStyle | Readonly<Ref<CSSProperties>> | Fixed-position styles for the ghost element | | getItemStyle | (index: number) => CSSProperties | Per-item styles: opacity:0 for the placeholder, translateY for animated neighbours | | getItemProps | (index: number) => DraggableItemProps | Props to spread on each draggable item (data-drag-index, onPointerdown, CSS classes) |

CSS classes added by getItemProps

| Class | When | |---|---| | vvsk-drag--dragging | Applied to the placeholder (the item being dragged) | | vvsk-drag--over | Applied to the current drop target | | vvsk-drag--disabled | Applied when isDragDisabled returns true |

Auto-scroll

When scrollContainer is provided, the list automatically scrolls up or down when the cursor enters a 60 px zone near the container edges. Scroll speed is proportional to the distance (max 14 px per frame).

Example

<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>

PositionManager

The internal segment tree exposed for advanced use cases. Stores row heights and answers prefix-sum queries in O(log n).

import { PositionManager } from 'vue-virtual-scroller-kit'

const manager = new PositionManager(
  10_000,          // item count
  50,              // uniform estimated height (or a function (index) => number)
)

manager.totalSize              // total height of all items
manager.getOffset(index)       // pixel offset of item i = sum of heights[0..i-1]
manager.getHeight(index)       // stored height of item i
manager.findIndex(scrollTop)   // first item index visible at scrollTop
manager.set(index, height)     // update measured height, O(log n)

Use PositionManager when building completely custom virtualised layouts that don't fit the provided components.


autoColWidths

Utility function that estimates column widths from a data sample using the Canvas API measureText. Useful for setting initial ColumnDef.width values in VirtualTable based on actual content.

import { autoColWidths } from 'vue-virtual-scroller-kit'

const widths = autoColWidths(columns, rows, options)

Parameters

| Parameter | Type | Description | |-----------|------|-------------| | cols | { key: string; title: string }[] | Column definitions — key and header title | | rows | T[] | Data rows to measure | | options | AutoColWidthsOptions | Optional settings (see below) |

AutoColWidthsOptions

| Option | Type | Default | Description | |--------|------|---------|-------------| | font | string | '12px sans-serif' | CSS font string — should match your cell font | | padding | number | 24 | Extra px added to measured width (accounts for cell padding) | | minWidth | number | 60 | Minimum column width in px | | maxWidth | number | 320 | Maximum column width in px |

Return value

Map<string, number> — maps each column key to a pixel width.

Example

import { autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'

const rawCols = [
  { key: 'id', title: 'ID' },
  { key: 'name', title: 'Name' },
  { key: 'email', title: 'Email' },
]

const widths = autoColWidths(rawCols, rows, {
  font: '12px Inter, sans-serif',
  padding: 24,
  maxWidth: 400,
})

const columns: ColumnDef[] = rawCols.map(c => ({
  key: c.key,
  title: c.title,
  width: widths.get(c.key) ?? 120,
  minWidth: 60,
}))

SSR note: autoColWidths uses document.createElement('canvas') internally. In SSR environments where document is unavailable it falls back to a uniform width of 120px.


TypeScript types

All public types are exported from the package root:

import type {
  // Column definition for VirtualTable
  ColumnDef,

  // Group definition for GroupedVirtualList
  GroupDef,

  // Scroll alignment
  ScrollAlign,           // 'start' | 'center' | 'end' | 'auto'

  // Visible range returned by useVirtualScroll
  VisibleRange,          // { start: number; end: number }

  // Exposed API of VirtualList (use instead of InstanceType for generic components)
  VirtualListExpose,

  // Exposed API of GroupedVirtualList
  GroupedVirtualListExpose,

  // Sort event payload from VirtualTable
  SortChange,            // { key: string; direction: 'asc' | 'desc' | null }

  // Low-level type for GroupedVirtualList row
  VirtualRow,
  VirtualRowType,

  // Tree types exported from VirtualTree
  TreeNode,
  FlatTreeRow,

  // Size provider for PositionManager / useVirtualScroll
  SizeProvider,          // number | ((index: number) => number)
} from 'vue-virtual-scroller-kit'

VirtualListExpose vs InstanceType

Generic Vue SFCs (generic="T") are not compatible with InstanceType<typeof Component>. Use the dedicated expose interfaces instead:

// ✗ Does not work for generic SFCs
const listRef = ref<InstanceType<typeof VirtualList> | null>(null)

// ✓ Correct
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'
const listRef = ref<VirtualListExpose | null>(null)

Accessibility

| Feature | Implementation | |---|---| | role="list" / role="listitem" | Applied on VirtualList container and each visible row | | aria-rowcount | Set to total item count on the list container | | aria-rowindex | Set to index + 1 on each visible row | | aria-busy | Set to "true" on the container while isLoading is true | | role="grid" / role="gridcell" | Used on VirtualGrid | | aria-rowindex / aria-colindex | Set on VirtualGrid cells | | role="treeitem" | Used on VirtualTree rows | | aria-expanded / aria-level | Set on tree rows with children | | role="combobox" / role="listbox" / role="option" | Used on VirtualSelect | | aria-expanded / aria-haspopup / aria-selected | Set on select trigger and options | | Keyboard support | Full keyboard navigation via useVirtualKeyboardNav |


SSR compatibility

All components render the first ssrPreloadCount rows (default 20) on the server with estimated heights. Client-side hydration replaces estimated positions with measured heights incrementally using ResizeObserver — no layout shift or scroll jump occurs.

Components that use browser-only APIs (ResizeObserver, requestAnimationFrame, window.scroll) guard those APIs with typeof window !== 'undefined' and onMounted. All core logic and slot rendering is SSR-safe.


Architecture

vue-virtual-scroller-kit
│
├── PositionManager (segment tree)
│     O(log n) height updates and prefix-sum queries
│
├── useVirtualScroll
│     itemCount + estimatedItemSize → visibleRange, totalHeight, scrollTo
│     ResizeObserver on scroll container (viewport resize)
│     RAF-batched recalc, debounced row measurements
│
├── VirtualList
│     scrollElement / pageMode / window scroll
│     ResizeObserver per visible row (dynamic heights)
│     Scroll restoration via sessionStorage
│     DOM recycling pool (recyclePool prop)
│
├── GroupedVirtualList
│     Flattens GroupDef[] → VirtualRow[] (headers + items)
│     Animated collapse/expand state machine per group
│     Backed by VirtualList
│
├── VirtualTable
│     Sticky header, fixed columns, sort, resize, column virtualization
│     Pinned top/bottom rows, built-in lazy loading (onLoadMore / hasMore / isLoading)
│     Backed by VirtualList
│
├── VirtualGrid
│     Auto-column count from container width (ResizeObserver)
│     rowHeightWithGap fed as Ref to useVirtualScroll
│     Backed by useVirtualScroll directly
│
├── VirtualTree
│     Recursive flattenNodes with lazy-load support
│     Backed by VirtualList
│
├── InfiniteLoader
│     Threshold check on scroll (debounced 50 ms)
│     Scroll-position preservation for up-direction prepend
│     Backed by VirtualList
│
├── VirtualSelect
│     Client-side filter, keyboard nav, open/close lifecycle
│     Backed by VirtualList
│
├── useVirtualKeyboardNav
│     Standalone composable — keydown on target or document
│
└── useDraggableList
      Pointer events (no HTML5 Drag API)
      Ghost element via fixed positioning + Teleport
      Gap animation via translateY on neighbours
      Auto-scroll RAF loop when near scroll container edges

Bundle size & peer dependencies

| Entry point | Peer deps | Notes | |---|---|---| | vue-virtual-scroller-kit | vue ^3.3 | Full bundle — all components and composables |

The package ships as tree-shakeable ESM (dist/index.js) + CJS (dist/index.cjs) dual build. Importing only VirtualList and leaving VirtualTable, VirtualTree, etc. unused results in those modules being dropped by your bundler.


License

MIT


Author

Danil Lisin Vladimirovich aka Macrulez

GitHub: macrulezru · Website: macrulez.ru/en

Questions and bugs — issues


💖 Support the project

Open source takes time and effort. If my work saves you time or brings value, consider supporting further development.

Thank you for being part of this journey. ❤️