Skip to content

Reference

TypeScript types

All public types are exported from the package root:

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

ts
// ✗ 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

FeatureImplementation
role="list" / role="listitem"Applied on VirtualList container and each visible row
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="gridcell"Used on VirtualGrid
aria-rowindex / aria-colindexSet on VirtualGrid cells
role="treeitem"Used on VirtualTree rows
aria-expanded / aria-levelSet on tree rows with children
role="combobox" / role="listbox" / role="option"Used on VirtualSelect
aria-expanded / aria-haspopup / aria-selectedSet on select trigger and options
Keyboard supportFull 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:

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

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

Performance

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.

Bundle size & peer dependencies

Entry pointPeer depsNotes
vue-virtual-scroller-kitvue ^3.3Full 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.

License

MIT