Skip to content

Composables

useVirtualScroll

The low-level composable that powers all components. Use it when you need to build a custom virtual container.

Options

OptionTypeDefaultDescription
itemCountnumber | Ref<number>Total item count
estimatedItemSizeSizeProvider | Ref<SizeProvider>50Estimated item height: number or (index) => number. A Ref triggers a full rebuild when it changes
overscannumber3Extra items rendered outside the viewport
getScrollElement() => HTMLElement | nullReturns the scroll container
pageModebooleanfalseUse window as the scroll container. Read once, not reactive — see note below
horizontalbooleanfalseVirtualize scrollLeft/clientWidth instead of scrollTop/clientHeight, RTL-safe via normalizeScrollLeft. Read once, not reactive — see note below
motionBlurbooleanfalseTrack scroll velocity and expose it as blurAmount (px). Off by default — zero cost when disabled
ts
type SizeProvider = number | ((index: number) => number)

pageMode and horizontal are captured from options once 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 calls useVirtualScroll (e.g. via :key).

Return value

PropertyTypeDescription
visibleRangeReadonly<Ref<VisibleRange>>{ start, end } — first and last visible item indices
totalHeightReadonly<Ref<number>>Total scrollable size in px along the scroll axis (height, or width when horizontal)
offsetTop(index: number) => numberPixel offset of item at index along the scroll axis (top, or left when horizontal)
scrollTo(index, align?, options?) => voidScroll to item ('start' | 'center' | 'end' | 'auto'). options.behavior is 'auto' (default, instant) or 'smooth'
scrollToOffset(offset: number, options?) => voidScroll to a raw pixel offset. Same options.behavior
measureItem(index, height) => voidReport a measured row height
handleScroll() => voidManually trigger a visible-range recalculation
blurAmountReadonly<Ref<number>>Current motion-blur radius in px. Always 0 unless the motionBlur option is enabled

Example

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

OptionTypeDefaultDescription
itemCountRef<number> | numberTotal item count
scrollTo(index, align?) => voidCalled to scroll the list when focus moves
targetRef<HTMLElement | null> | HTMLElementdocumentElement that receives keyboard events
onActivate(index: number) => voidCalled on Enter or Space
onChange(index: number) => voidCalled when the focused index changes
loopbooleanfalseWhether to wrap around at boundaries

Return value

PropertyTypeDescription
focusedIndexReadonly<Ref<number>>Currently focused index, -1 if nothing focused
setFocus(index: number) => voidProgrammatically set focus
isFocused(index: number) => booleanWhether index is focused

Keys handled

KeyAction
Move focus up
Move focus down
HomeFocus first item
EndFocus last item
PageUpJump −10 items
PageDownJump +10 items
Enter / SpaceCall onActivate

Example

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

OptionTypeDefaultDescription
itemsRef<T[]>Reactive items array
onReorder(newItems, from, to) => voidCalled after a successful drop with the reordered array
isDragDisabled(item, index) => booleanReturn true to prevent an item from being dragged
scrollContainerHTMLElement | Ref<HTMLElement | null>Container for auto-scroll when dragging near its edges

Return value

PropertyTypeDescription
dragIndexReadonly<Ref<number>>Index of the item being dragged, -1 when idle
overIndexReadonly<Ref<number>>Index of the current drop target
isDraggingReadonly<Ref<boolean>>Whether a drag is in progress
ghostStyleReadonly<Ref<CSSProperties>>Fixed-position styles for the ghost element
getItemStyle(index: number) => CSSPropertiesPer-item styles: opacity:0 for the placeholder, translateY for animated neighbours
getItemProps(index: number) => DraggableItemPropsProps to spread on each draggable item (data-drag-index, onPointerdown, CSS classes)

CSS classes added by getItemProps

ClassWhen
vvsk-drag--draggingApplied to the placeholder (the item being dragged)
vvsk-drag--overApplied to the current drop target
vvsk-drag--disabledApplied 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

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

OptionTypeDefaultDescription
itemsRef<T[]> | ComputedRef<T[]>Reactive items array
getKey(item: T, index: number) => string | numberitem.id ?? indexIdentity used for the selection set
multiplebooleantruefalse restricts to a single selected row; a new toggle call replaces the selection instead of adding to it

Return value

PropertyTypeDescription
selectedKeysReadonly<Ref<Set<string | number>>>Currently selected keys
selectedItemsComputedRef<T[]>Currently selected items, derived from items + selectedKeys
isSelected(item: T, index: number) => booleanWhether a row is selected
toggle(item: T, index: number, event?: MouseEvent | KeyboardEvent) => voidToggle 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() => voidSelect every row in items
clearSelection() => voidClear the selection and reset the shift-range anchor

Example

vue
<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 the index now available on the #cell slot — 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

OptionTypeDefaultDescription
root() => HTMLElement | nullReturns the scroll container to intersect against. Omit to use the browser viewport
rootMarginstring'0px'IntersectionObserver rootMargin — grow/shrink the root's effective bounds (e.g. trigger slightly early)
thresholdnumber | number[]0Fraction of the element that must be visible to count as "visible"
onVisible(key: string | number) => voidCalled when a tracked key becomes visible
onHidden(key: string | number) => voidCalled when a tracked key becomes hidden (including via unobserve while it was visible)

Return value

PropertyTypeDescription
visibleKeysReadonly<Ref<Set<string | number>>>Keys currently intersecting the root
isVisible(key: string | number) => booleanWhether key is currently visible
observe(el: Element | null, key: string | number) => voidStart tracking an element under key — bind via a template ref callback
unobserve(key: string | number) => voidStop tracking key (e.g. on row unmount)

root may resolve after this composable's own setup runs (e.g. a sibling VirtualList'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":

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