Reference
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)
│ containerRole/itemRole — overridable ARIA roles so a wrapping
│ component (VirtualTree, VirtualSelect) can suppress list/listitem
│ and own the accessibility tree itself
│
├── 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)
│ Per-row-type size estimate (estimatedItemSize vs estimatedGroupHeaderSize)
│ 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
│ containerRole/rowRole/itemRole — overridable ARIA roles (default
│ grid/row/gridcell) so a wrapping widget can suppress them the same
│ way VirtualList's containerRole/itemRole work
│ Backed by useVirtualScroll directly
│
├── VirtualTree
│ Recursive flattenNodes with lazy-load support
│ container-role="tree" / item-role="none" on the inner VirtualList,
│ so its own role="treeitem" rows compose correctly
│ 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
│ container-role="none" / item-role="none" on the inner VirtualList,
│ so the dropdown's own role="listbox"/role="option" compose correctly
│ Backed by VirtualList
│
├── VirtualScrollbar
│ Decoupled from useVirtualScroll — syncs to any getScrollElement()
│ via its own scroll/ResizeObserver listeners
│ Pointer-drag thumb + click-to-jump track
│ role="scrollbar" thumb with full keyboard support (see Accessibility)
│
├── useVirtualKeyboardNav
│ Standalone composable — keydown on target or document
│
├── useDraggableList
│ Pointer events only (no HTML5 Drag API — no native draggable attribute)
│ 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 changesSSR 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.
Accessibility
| Feature | Implementation |
|---|---|
role="list" / role="listitem" | Applied on VirtualList container and each visible row (overridable via containerRole/itemRole) |
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="row" / role="gridcell" | Used on VirtualGrid (overridable via containerRole/rowRole/itemRole) — each row of cells is wrapped in role="row" |
aria-rowcount / aria-colcount | Set on the VirtualGrid container, only while containerRole stays in the grid/table family |
aria-rowindex / aria-colindex | Set on VirtualGrid rows and cells, only while rowRole/itemRole stay in the row/cell family |
role="tree" / role="treeitem" | Used on VirtualTree — the inner VirtualList suppresses its own list/listitem roles via containerRole="tree"/itemRole="none" so the tree composes without an extra nesting layer |
aria-expanded / aria-level | Set on tree rows with children |
role="combobox" / role="listbox" / role="option" | Used on VirtualSelect — the inner VirtualList suppresses its own roles the same way as VirtualTree |
aria-expanded / aria-haspopup / aria-selected | Set on select trigger and options |
role="scrollbar" | Used on VirtualScrollbar's thumb |
aria-orientation / aria-valuemin / aria-valuemax / aria-valuenow | Set on VirtualScrollbar's thumb, reflecting the live scroll position |
Keyboard support (VirtualScrollbar) | Arrow keys step, Home/End jump to the start/end, PageUp/PageDown jump by a viewport |
aria-sort | Set on VirtualTable's sortable headers ("ascending", "descending", or "none") |
| Keyboard support (lists) | Full keyboard navigation via useVirtualKeyboardNav |
Bundle size & peer dependencies
| Entry point | Peer deps | Notes |
|---|---|---|
vue-virtual-scroller-kit | vue >=3.3.0 | 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.
Benchmarks
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.
License
MIT