Reference
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'
// Options for scrollTo/scrollToOffset — { behavior?: 'auto' | 'smooth' }
ScrollBehaviorOptions,
// 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)
// Options/return types for useRowSelection
UseRowSelectionOptions,
UseRowSelectionReturn,
// Options/return types for useVisibilityTracker
UseVisibilityTrackerOptions,
UseVisibilityTrackerReturn,
} 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, IntersectionObserver, requestAnimationFrame, window.scroll) guard those APIs with typeof window !== 'undefined' and onMounted. All core logic and slot rendering is SSR-safe.
RTL support
Wrap your app (or just the parts using this library) in dir="rtl" — no rtl prop exists anywhere, because none is needed:
<VirtualTable :columns="columns" :rows="rows" dir="rtl" style="height: 500px" />Every component uses CSS logical properties (inset-inline-start/inset-inline-end, padding-inline-start, margin-inline-start) instead of physical left/right for positioning — VirtualTree indent, VirtualTable fixed columns and the column-resize handle, VirtualGrid cell placement, and VirtualScrollbar's horizontal thumb all mirror automatically whenever the browser resolves direction: rtl, with zero JavaScript direction-branching.
The one place that genuinely needs JS is element.scrollLeft, whose sign/origin differs in RTL across browsers (0 at the start edge, negative going toward the end, per the modern spec). VirtualTable's column virtualization, VirtualScrollbar's horizontal mode, and VirtualList's horizontal layout all read/write it through normalizeScrollLeft / setNormalizedScrollLeft rather than the raw property — both are also exported from vue-virtual-scroller-kit if you need the same normalization in your own code. Column drag-to-resize also flips its pointer-delta sign in RTL, since the resize handle sits on the physical left edge there.
import { normalizeScrollLeft, setNormalizedScrollLeft } from 'vue-virtual-scroller-kit'
const distanceFromStart = normalizeScrollLeft(el) // works the same in LTR and RTL
setNormalizedScrollLeft(el, distanceFromStart + 100)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
│ Anchor-compensated reflow: height changes above the viewport nudge
│ scrollTop by the same delta so visible rows never jump
│ Optional velocity tracking → blurAmount (motionBlur option)
│ Optional horizontal axis (scrollLeft/clientWidth, RTL-safe via
│ normalizeScrollLeft); fixed at mount, like pageMode
│
├── VirtualList
│ scrollElement / pageMode / window scroll
│ ResizeObserver per visible row (dynamic heights)
│ Scroll restoration via sessionStorage
│ DOM recycling pool (recyclePool prop)
│ Optional horizontal layout (horizontal prop)
│
├── GroupedVirtualList
│ Flattens GroupDef[] → VirtualRow[] (headers + items)
│ Animated collapse/expand state machine per group
│ Optional sticky-header overlay (stickyGroupHeaders), tracks the group
│ at visibleRange.start — not a real CSS sticky row (rows are absolute)
│ Backed by VirtualList
│
├── VirtualTable
│ Sticky header, fixed columns, sort, resize, reorder, column virtualization
│ Pinned top/bottom rows, built-in lazy loading (onLoadMore / hasMore / isLoading)
│ Column order (columnOrder) and visibility (hiddenColumnKeys) tracked
│ internally, both overlay the columns prop
│ Backed by VirtualList
│
├── VirtualGrid
│ Auto-column count from container width (ResizeObserver)
│ rowHeightWithGap fed as Ref to useVirtualScroll
│ Optional dynamicRowHeight: per-row wrapper (flex) + ResizeObserver,
│ row height = max of that row's cells, instead of individually
│ absolutely-positioned fixed-height cells
│ 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 or opt-in remote mode (debounced search event,
│ isLoading slot), keyboard nav, open/close lifecycle
│ Backed by VirtualList
│
├── VirtualScrollbar
│ Decoupled from useVirtualScroll — syncs to any getScrollElement()
│ via its own scroll/ResizeObserver listeners
│ Pointer-drag thumb + click-to-jump track
│
├── 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
│
├── useRowSelection
│ Dataset-agnostic (works with VirtualList, VirtualTable, plain arrays)
│ Click toggles in place; Shift+click fills a range from the
│ last-toggled index (Gmail-checkbox convention)
│
└── useVisibilityTracker
Per-key observe()/unobserve(), backed by a real IntersectionObserver
(not visibleRange diffing — accurate under overscan/partial thresholds)
Root polled + rebuilt automatically if it resolves late or changesPerformance
src/__bench__/ has Vitest benchmarks (vitest bench, tinybench under the hood) for the two pieces that carry the package's O(log n) claim: the PositionManager segment tree directly, and useVirtualScroll's mount/rebuild cost on top of it. Run them yourself with:
npm run benchBenchmarking is an experimental Vitest feature — numbers can shift between Vitest versions. These are isolated micro-benchmarks in jsdom (component mount/unmount, no real paint or layout), not a substitute for profiling an actual app. Treat them as relative evidence of the O(log n) design, not as absolute numbers for your hardware.
PositionManager — per-operation mean time, one developer machine, single run:
| Items (n) | construct (fixed height) | findIndex | set (resize) | getOffset |
|---|---|---|---|---|
| 1,000 | 0.0125 ms | 0.0001 ms | 0.0001 ms | 0.0001 ms |
| 10,000 | 0.166 ms | 0.0002 ms | 0.0001 ms | 0.0002 ms |
| 100,000 | 0.612 ms | 0.0002 ms | 0.0002 ms | 0.0002 ms |
construct is the one O(n) operation (it builds the whole tree once) — its cost grows with n, as expected. findIndex, set, and getOffset are the O(log n) operations queried on every scroll event and every row measurement; their per-call cost barely moves from 1,000 to 100,000 items — this is the segment tree doing its job.
useVirtualScroll — mounting the composable in a real Vue component, and rebuilding its internal PositionManager on an itemCount change:
| Items (n) | Mount | itemCount change (rebuild) |
|---|---|---|
| 1,000 | 0.204 ms | 0.206 ms |
| 10,000 | 0.352 ms | 0.373 ms |
| 100,000 | 1.51 ms | 2.22 ms |
Both scale with the PositionManager construction cost inside them, plus Vue's own mount/reactivity overhead — still comfortably sub-millisecond to low-millisecond even at 100k rows.
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.
Development
npm install
npm run typecheck # vue-tsc
npm run lint # eslint
npm run lint:css # stylelint
npm test # vitest — unit tests (src/__tests__)
npm run demo # starts the demo app at http://localhost:5173End-to-end tests drive the demo app in a real browser (Playwright) and live in demo/e2e/ — this is what actually catches scroll-timing and browser-API bugs that jsdom can't (real smooth-scroll animation, real ResizeObserver/requestAnimationFrame timing, real RTL layout):
cd demo
npm install
npm run test:e2eCI (.github/workflows/ci.yml) runs typecheck, lint, unit tests, and build on Node 20 and 22, plus the e2e suite, on every push and pull request.
License
MIT