Composables
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. Read once, not reactive — see note below |
horizontal | boolean | false | Virtualize scrollLeft/clientWidth instead of scrollTop/clientHeight, RTL-safe via normalizeScrollLeft. Read once, not reactive — see note below |
motionBlur | boolean | false | Track scroll velocity and expose it as blurAmount (px). Off by default — zero cost when disabled |
type SizeProvider = number | ((index: number) => number)
pageModeandhorizontalare captured fromoptionsonce when the composable is set up — changing them on a live instance has no effect. If you need to switch axes at runtime, remount the component that callsuseVirtualScroll(e.g. via:key).
Return value
| Property | Type | Description |
|---|---|---|
visibleRange | Readonly<Ref<VisibleRange>> | { start, end } — first and last visible item indices |
totalHeight | Readonly<Ref<number>> | Total scrollable size in px along the scroll axis (height, or width when horizontal) |
offsetTop | (index: number) => number | Pixel offset of item at index along the scroll axis (top, or left when horizontal) |
scrollTo | (index, align?, options?) => void | Scroll to item ('start' | 'center' | 'end' | 'auto'). options.behavior is 'auto' (default, instant) or 'smooth' |
scrollToOffset | (offset: number, options?) => void | Scroll to a raw pixel offset. Same options.behavior |
measureItem | (index, height) => void | Report a measured row height |
handleScroll | () => void | Manually trigger a visible-range recalculation |
blurAmount | Readonly<Ref<number>> | Current motion-blur radius in px. Always 0 unless the motionBlur option is enabled |
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>useRowSelection
Dataset-agnostic click / Shift-click row selection — works with VirtualList, VirtualTable, or any plain array. Not tied to a specific component: pair isSelected/toggle with whichever row-index slot scope your component exposes.
Options
| Option | Type | Default | Description |
|---|---|---|---|
items | Ref<T[]> | ComputedRef<T[]> | — | Reactive items array |
getKey | (item: T, index: number) => string | number | item.id ?? index | Identity used for the selection set |
multiple | boolean | true | false restricts to a single selected row; a new toggle call replaces the selection instead of adding to it |
Return value
| Property | Type | Description |
|---|---|---|
selectedKeys | Readonly<Ref<Set<string | number>>> | Currently selected keys |
selectedItems | ComputedRef<T[]> | Currently selected items, derived from items + selectedKeys |
isSelected | (item: T, index: number) => boolean | Whether a row is selected |
toggle | (item: T, index: number, event?: MouseEvent | KeyboardEvent) => void | Toggle a row. event.shiftKey fills the range from the last-toggled row to this one (Gmail-checkbox convention), on top of whatever's already selected |
selectAll | () => void | Select every row in items |
clearSelection | () => void | Clear the selection and reset the shift-range anchor |
Example
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useRowSelection } from 'vue-virtual-scroller-kit'
interface Row {
id: number
label: string
}
const items = ref<Row[]>(Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Row ${i + 1}` })))
const selection = useRowSelection<Row>({ items })
</script>
<template>
<VirtualList :items="items" key-field="id" :estimated-item-size="40" style="height: 500px">
<template #default="{ item, index }">
<label>
<input
type="checkbox"
:checked="selection.isSelected(item, index)"
@click="selection.toggle(item, index, $event)"
/>
{{ item.label }}
</label>
</template>
</VirtualList>
<p>{{ selection.selectedItems.value.length }} selected</p>
</template>Click toggles one row in place; Shift+click fills the range from the last-toggled row. For
VirtualTable, pair this with theindexnow available on the#cellslot — see the row-selection example on the VirtualTable page.
useVisibilityTracker
Per-key "entered viewport" / "left viewport" tracking, backed by a real IntersectionObserver rather than diffing visibleRange — so it's accurate even with a large overscan buffer or partial-visibility thresholds. Dataset-agnostic: you decide which elements to observe(), each under whatever key you like (a row id, an index, anything).
Typical use: highlight a nav/minimap entry, a table-of-contents item, or a "jump to" button in a control panel while the corresponding row is actually on screen in a virtualized list or table — turning it off the instant the row scrolls back out.
Options
| Option | Type | Default | Description |
|---|---|---|---|
root | () => HTMLElement | null | — | Returns the scroll container to intersect against. Omit to use the browser viewport |
rootMargin | string | '0px' | IntersectionObserver rootMargin — grow/shrink the root's effective bounds (e.g. trigger slightly early) |
threshold | number | number[] | 0 | Fraction of the element that must be visible to count as "visible" |
onVisible | (key: string | number) => void | — | Called when a tracked key becomes visible |
onHidden | (key: string | number) => void | — | Called when a tracked key becomes hidden (including via unobserve while it was visible) |
Return value
| Property | Type | Description |
|---|---|---|
visibleKeys | Readonly<Ref<Set<string | number>>> | Keys currently intersecting the root |
isVisible | (key: string | number) => boolean | Whether key is currently visible |
observe | (el: Element | null, key: string | number) => void | Start tracking an element under key — bind via a template ref callback |
unobserve | (key: string | number) => void | Stop tracking key (e.g. on row unmount) |
rootmay resolve after this composable's own setup runs (e.g. a siblingVirtualList's template ref) — it's polled for a few frames, and rebuilt automatically if the resolved root element ever changes (such as after a:key-forced remount).
Example
Watch specific rows and mirror their visibility into a sidebar panel — same pattern used by the VirtualList demo's "Watchlist":
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useVisibilityTracker } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'
interface Row {
id: number
title: string
}
const items = ref<Row[]>(
Array.from({ length: 100_000 }, (_, i) => ({ id: i + 1, title: `Row ${i + 1}` })),
)
const listRef = ref<VirtualListExpose | null>(null)
const watchedIds = ref<Set<number>>(new Set([1, 50_000]))
const tracker = useVisibilityTracker({
root: () => listRef.value?.getScrollElement() ?? null,
})
// Rows unmount when scrolled out of the virtualized range, so track/untrack on
// mount/unmount rather than assuming an observed element stays alive.
function onRowMount(el: Element, id: number) {
if (watchedIds.value.has(id)) tracker.observe(el, id)
}
function onRowUnmount(id: number) {
tracker.unobserve(id)
}
</script>
<template>
<VirtualList
ref="listRef"
:items="items"
key-field="id"
:estimated-item-size="48"
style="height: 500px"
>
<template #default="{ item }">
<div
:ref="(el) => el && onRowMount(el as Element, item.id)"
@vue:unmounted="onRowUnmount(item.id)"
:style="{
background:
watchedIds.has(item.id) && tracker.isVisible(item.id) ? '#fef08a' : 'transparent',
}"
>
{{ item.title }}
</div>
</template>
</VirtualList>
</template>