Skip to content

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 changes

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.

Accessibility ​

FeatureImplementation
role="list" / role="listitem"Applied on VirtualList container and each visible row (overridable via containerRole/itemRole)
aria-rowcountSet to total item count on the list container
aria-rowindexSet to index + 1 on each visible row
aria-busySet 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-colcountSet on the VirtualGrid container, only while containerRole stays in the grid/table family
aria-rowindex / aria-colindexSet 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-levelSet 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-selectedSet on select trigger and options
role="scrollbar"Used on VirtualScrollbar's thumb
aria-orientation / aria-valuemin / aria-valuemax / aria-valuenowSet 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-sortSet on VirtualTable's sortable headers ("ascending", "descending", or "none")
Keyboard support (lists)Full keyboard navigation via useVirtualKeyboardNav

Bundle size & peer dependencies ​

Entry pointPeer depsNotes
vue-virtual-scroller-kitvue >=3.3.0Full 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 ​

bash
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:5173

End-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):

bash
cd demo
npm install
npm run test:e2e

CI (.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:

bash
npm run bench

Benchmarking 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)findIndexset (resize)getOffset
1,0000.0125 ms0.0001 ms0.0001 ms0.0001 ms
10,0000.166 ms0.0002 ms0.0001 ms0.0002 ms
100,0000.612 ms0.0002 ms0.0002 ms0.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)MountitemCount change (rebuild)
1,0000.204 ms0.206 ms
10,0000.352 ms0.373 ms
100,0001.51 ms2.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