30% offEnding soon

useVirtualList: Build and Explain Virtual Lists

22 min read

The name useVirtualList refers to different hooks in VueUse, ahooks, and Rooks. In each case, the central idea is to render a small window of rows while preserving the scroll range of the complete collection.

useVirtualList calculates which items intersect a scrollable viewport, adds buffered items around that range, and positions the resulting DOM window inside a spacer that represents the full list.

One tall range, a few real rowsfull spacerlogicallist heightbuffer rowvisible rowvisible rowbuffer rowvisibleviewportEmpty spacer regions contain no row elements.
A full-height spacer preserves scrolling while only a small row window is mounted.

Which useVirtualList Are You Looking For?

VueUse provides useVirtualList for Vue. Its hook returns list, scrollTo, containerProps, and wrapperProps. The worked library example below uses this API.

The ahooks package has a React hook with the same name. It accepts a numeric item height or an item-height callback, includes overscan, and returns a rendered slice with a scrollTo function.

Rooks also exports useVirtualList for React from rooks/experimental. Its documentation describes fixed-size vertical and horizontal lists and warns that the experimental hook may change in a minor release.

These hooks resemble one another, but their imports, returned values, and component bindings are not interchangeable. A VueUse example copied into a React interview project will not become valid by changing the import path.

Same idea, different connectionsrangecalculationVueUseVue propsahooksReact sliceRooksReact APIChanging the import does not changethe component contract.
The range calculation is shared; the framework contracts are not.

The range calculation itself does not depend on a framework. That calculation is the useful interview problem, so the implementation later in this article separates a TypeScript range calculator from its Vue adapter. The related Virtualized List exercise is useful when the interview focuses on component behavior rather than a named hook.

How Virtual List Rendering Works

A viewport is the visible area of the scroll container. Its edges determine the visible range:

visibleEnd   = ceil((scrollTop + viewportHeight) / itemHeight)
visibleCount = visibleEnd - firstVisible

If the viewport begins on a row boundary, is 240 pixels tall, and every row is 40 pixels tall, six rows fit inside it.

For a fixed row height, the scroll position identifies the first visible index:

firstVisible = floor(scrollTop / itemHeight)

Suppose scrollTop is 410 and each row is 40 pixels tall. The calculation returns index 10 because the first 400 pixels contain ten complete rows.

Overscan adds buffered items outside the visible window. With an overscan value of 2, the rendered boundaries are:

start = max(0, firstVisible - overscan)
end   = min(itemCount, visibleEnd + overscan)

end is exclusive. For scrollTop 410 and a 240-pixel viewport, the range from 8 to 19 therefore contains indexes 8 through 18.

Rendered range [8, 19)start = 88–9top buffer10–16visible rows7 items17–18bottom bufferend 19The boundary is named; the item at it is excluded.
The range [8, 19) includes 8 through 18, but not 19.

The browser still needs a scrollbar that represents the entire collection. A spacer or wrapper supplies that logical size:

totalSize = itemCount * itemHeight

The rendered rows begin at the position represented by start. Their offset inside the spacer is:

itemOffset = start * itemHeight

A collection of 10,000 fixed-height rows can therefore have a full-height spacer while only the current visible range and its buffers exist as row elements. The Virtual Scroll List exercise develops the same spacer technique without relying on a library hook.

Overscan trades DOM work for scroll tolerance. A larger buffer keeps more rows ready when the scroll position changes quickly. It also mounts more elements and performs more rendering work. The right value depends on row cost, viewport size, and scrolling behavior, so it should be tested rather than selected from a universal threshold.

What happens during a fast scroll?No overscanmountedrowsblank untilrenderfewer nodesWith overscanvisiblerowsready buffernew viewportmore nodesThe orange rows buy time for the next render.
Overscan spends extra DOM nodes to cover movement beyond the current viewport.

Using VueUse useVirtualList

Install VueUse through the package manager used by the project:

npm install @vueuse/core

This Vue component virtualizes a filtered collection. Each record has a stable id, so filtering does not turn the current array index into a misleading component identity.

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useVirtualList } from '@vueuse/core'

type Result = {
  id: string
  name: string
}

const query = ref('')
const results = ref<Result[]>(
  Array.from({ length: 1000 }, (_, index) => ({
    id: `result-${index + 1}`,
    name: `Interview question ${index + 1}`,
  })),
)

const filteredResults = computed(() => {
  const term = query.value.trim().toLowerCase()

  return term
    ? results.value.filter(result =>
        result.name.toLowerCase().includes(term),
      )
    : results.value
})

const {
  list,
  scrollTo,
  containerProps,
  wrapperProps,
} = useVirtualList(filteredResults, {
  itemHeight: 40,
  overscan: 5,
})

function jumpToStart() {
  scrollTo(0)
}
</script>

<template>
  <label>
    Filter questions
    <input v-model="query">
  </label>

  <button type="button" @click="jumpToStart">
    Go to first result
  </button>

  <div v-bind="containerProps" class="result-viewport">
    <ul v-bind="wrapperProps" role="list">
      <li
        v-for="row in list"
        :key="row.data.id"
        :aria-posinset="row.index + 1"
        :aria-setsize="filteredResults.length"
      >
        {{ row.data.name }}
      </li>
    </ul>
  </div>
</template>

<style scoped>
.result-viewport {
  height: 240px;
  overflow-y: auto;
}

.result-viewport ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.result-viewport li {
  box-sizing: border-box;
  height: 40px;
}
</style>

containerProps connects the constrained scrolling element to the hook. wrapperProps gives the inner element the size and offset needed to represent the full list. Each entry in the returned list contains the source item in data and its current source position in index.

filteredResults is reactive. When the filter changes its length or contents, the hook recalculates the virtual range. A hand-written virtualizer must establish the same dependency. Caching a range by scroll position alone leaves stale indexes after filtering.

VueUse uses itemWidth instead of itemHeight for a horizontal list:

const horizontal = useVirtualList(filteredResults, {
  itemWidth: 160,
  overscan: 5,
})

The horizontal container also needs a constrained width and horizontal overflow. Only one axis option should describe a given list.

Both size options may receive a callback based on the item index. Such a callback must already know the size. It does not measure the rendered element after layout.

Implement useVirtualList From Scratch

The canonical implementation starts with a framework-neutral fixed-height calculator. It validates its inputs, clamps the scroll position, and returns an exclusive end index.

// virtual-range.ts
export type VirtualRange = {
  start: number
  end: number
  firstVisible: number
  visibleCount: number
  totalSize: number
  itemOffset: number
}

function requirePositiveSize(name: string, value: number): void {
  if (!Number.isFinite(value) || value <= 0) {
    throw new RangeError(`${name} must be a positive finite number`)
  }
}

function requireCount(name: string, value: number): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${name} must be a non-negative integer`)
  }
}

export function calculateVirtualRange(
  itemCount: number,
  itemHeight: number,
  viewportHeight: number,
  scrollTop: number,
  overscan = 0,
): VirtualRange {
  requireCount('itemCount', itemCount)
  requirePositiveSize('itemHeight', itemHeight)
  requireCount('overscan', overscan)

  if (!Number.isFinite(viewportHeight) || viewportHeight < 0) {
    throw new RangeError(
      'viewportHeight must be a non-negative finite number',
    )
  }

  const totalSize = itemCount * itemHeight

  if (itemCount === 0) {
    return {
      start: 0,
      end: 0,
      firstVisible: 0,
      visibleCount: 0,
      totalSize: 0,
      itemOffset: 0,
    }
  }

  const normalizedScrollTop =
    Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0
  const maximumScrollTop = Math.max(0, totalSize - viewportHeight)
  const clampedScrollTop = Math.min(
    normalizedScrollTop,
    maximumScrollTop,
  )

  const firstVisible = Math.floor(clampedScrollTop / itemHeight)
  const visibleEnd =
    viewportHeight === 0
      ? firstVisible
      : Math.min(
          itemCount,
          Math.ceil(
            (clampedScrollTop + viewportHeight) / itemHeight,
          ),
        )
  const visibleCount = visibleEnd - firstVisible

  const start = Math.max(0, firstVisible - overscan)
  const end = Math.min(itemCount, visibleEnd + overscan)

  return {
    start,
    end,
    firstVisible,
    visibleCount,
    totalSize,
    itemOffset: start * itemHeight,
  }
}

export function scrollOffsetForIndex(
  index: number,
  itemCount: number,
  itemHeight: number,
  viewportHeight: number,
): number {
  requireCount('itemCount', itemCount)
  requirePositiveSize('itemHeight', itemHeight)

  if (!Number.isFinite(viewportHeight) || viewportHeight < 0) {
    throw new RangeError(
      'viewportHeight must be a non-negative finite number',
    )
  }

  if (itemCount === 0) return 0

  const normalizedIndex = Number.isFinite(index)
    ? Math.trunc(index)
    : 0
  const clampedIndex = Math.min(
    itemCount - 1,
    Math.max(0, normalizedIndex),
  )
  const maximumScrollTop = Math.max(
    0,
    itemCount * itemHeight - viewportHeight,
  )

  return Math.min(clampedIndex * itemHeight, maximumScrollTop)
}

The Vue adapter keeps browser behavior outside the calculator. ResizeObserver updates the viewport height when the container changes size. Scroll events update scrollTop, and changes to the source length clamp a position that may no longer exist.

// useFixedVirtualList.ts
import {
  computed,
  onScopeDispose,
  ref,
  watch,
  type CSSProperties,
  type Ref,
} from 'vue'
import {
  calculateVirtualRange,
  scrollOffsetForIndex,
} from './virtual-range'

type FixedVirtualListOptions = {
  itemHeight: number
  overscan?: number
}

export function useFixedVirtualList<T>(
  source: () => readonly T[],
  options: FixedVirtualListOptions,
) {
  const containerRef: Ref<HTMLElement | null> = ref(null)
  const viewportHeight = ref(0)
  const scrollTop = ref(0)

  const range = computed(() =>
    calculateVirtualRange(
      source().length,
      options.itemHeight,
      viewportHeight.value,
      scrollTop.value,
      options.overscan ?? 0,
    ),
  )

  const list = computed(() =>
    source()
      .slice(range.value.start, range.value.end)
      .map((data, localIndex) => ({
        data,
        index: range.value.start + localIndex,
      })),
  )

  const wrapperProps = computed(() => ({
    style: {
      height: `${range.value.totalSize}px`,
      position: 'relative',
    } satisfies CSSProperties,
  }))

  function itemStyle(index: number): CSSProperties {
    return {
      boxSizing: 'border-box',
      height: `${options.itemHeight}px`,
      position: 'absolute',
      top: `${index * options.itemHeight}px`,
      width: '100%',
    }
  }

  function onScroll(event: Event): void {
    scrollTop.value = (event.currentTarget as HTMLElement).scrollTop
  }

  function clampCurrentScroll(): void {
    const element = containerRef.value
    if (!element) return

    const next = scrollOffsetForIndex(
      source().length - 1,
      source().length,
      options.itemHeight,
      viewportHeight.value,
    )

    if (element.scrollTop > next) {
      element.scrollTop = next
    }

    scrollTop.value = element.scrollTop
  }

  function scrollTo(index: number): void {
    const element = containerRef.value
    if (!element) return

    const next = scrollOffsetForIndex(
      index,
      source().length,
      options.itemHeight,
      viewportHeight.value,
    )

    element.scrollTop = next
    scrollTop.value = next
  }

  const containerProps = {
    ref: containerRef,
    onScroll,
  }

  let observer: ResizeObserver | undefined

  watch(
    containerRef,
    element => {
      observer?.disconnect()
      observer = undefined

      if (!element) return

      const updateViewport = () => {
        viewportHeight.value = element.clientHeight
        clampCurrentScroll()
      }

      updateViewport()

      if (typeof ResizeObserver !== 'undefined') {
        observer = new ResizeObserver(updateViewport)
        observer.observe(element)
      }
    },
    { flush: 'post' },
  )

  watch(
    () => source().length,
    () => clampCurrentScroll(),
    { flush: 'post' },
  )

  onScopeDispose(() => observer?.disconnect())

  return {
    containerProps,
    itemStyle,
    list,
    range,
    scrollTo,
    wrapperProps,
  }
}

This component consumes the adapter. It contains the constrained container, stable keys, full spacer, absolute row positions, and collection-position attributes.

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFixedVirtualList } from './useFixedVirtualList'

type Question = {
  id: string
  title: string
}

const allQuestions = ref<Question[]>([])
const query = ref('')

const filteredQuestions = computed(() => {
  const term = query.value.trim().toLowerCase()
  return allQuestions.value.filter(question =>
    question.title.toLowerCase().includes(term),
  )
})

const {
  containerProps,
  itemStyle,
  list,
  scrollTo,
  wrapperProps,
} = useFixedVirtualList(
  () => filteredQuestions.value,
  { itemHeight: 44, overscan: 4 },
)
</script>

<template>
  <label>
    Filter questions
    <input v-model="query">
  </label>

  <button type="button" @click="scrollTo(0)">
    Go to first result
  </button>

  <div v-bind="containerProps" class="question-viewport">
    <ul v-bind="wrapperProps" role="list">
      <li
        v-for="row in list"
        :key="row.data.id"
        :style="itemStyle(row.index)"
        :aria-posinset="row.index + 1"
        :aria-setsize="filteredQuestions.length"
      >
        {{ row.data.title }}
      </li>
    </ul>
  </div>
</template>

<style scoped>
.question-viewport {
  height: 264px;
  overflow-y: auto;
}

.question-viewport ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
</style>

The adapter requires ResizeObserver for automatic resize updates. In an environment without that browser API, it records the initial clientHeight but will not notice later container resizes unless the application supplies another update mechanism.

Test the Boundaries Interviewers Look For

The following TypeScript test imports the calculator readers will use. Run it with a TypeScript-capable test runner. It covers empty data, a viewport smaller than one row, a non-row-aligned viewport, a partial final row, overscan at both boundaries, invalid inputs, resize inputs, filtered data, and clamped scroll targets.

import * as assert from 'node:assert/strict'
import {
  calculateVirtualRange,
  scrollOffsetForIndex,
} from './virtual-range'

const empty = calculateVirtualRange(0, 30, 90, 0, 2)
assert.deepEqual(empty, {
  start: 0,
  end: 0,
  firstVisible: 0,
  visibleCount: 0,
  totalSize: 0,
  itemOffset: 0,
})
console.log('empty:', empty.start, empty.end, empty.totalSize)

const unaligned = calculateVirtualRange(100, 40, 240, 410, 0)
assert.equal(unaligned.start, 10)
assert.equal(unaligned.end, 17)
assert.equal(unaligned.visibleCount, 7)

assert.throws(
  () => scrollOffsetForIndex(0, -1, 40, 240),
  RangeError,
)
assert.throws(
  () => scrollOffsetForIndex(0, 1.5, 40, 240),
  RangeError,
)
assert.throws(
  () => scrollOffsetForIndex(0, 10, 40, -1),
  RangeError,
)

const shortViewport = calculateVirtualRange(10, 30, 10, 0)
assert.equal(shortViewport.visibleCount, 1)
console.log(
  'short viewport:',
  shortViewport.start,
  shortViewport.end,
)

const partialLastRow = calculateVirtualRange(5, 30, 65, 60)
assert.deepEqual(
  [partialLastRow.start, partialLastRow.end, partialLastRow.itemOffset],
  [2, 5, 60],
)
console.log(
  'partial last row:',
  partialLastRow.start,
  partialLastRow.end,
  partialLastRow.itemOffset,
)

const top = calculateVirtualRange(100, 20, 60, 0, 2)
const bottom = calculateVirtualRange(100, 20, 60, 9999, 2)
assert.deepEqual([top.start, top.end], [0, 5])
assert.deepEqual(
  [bottom.start, bottom.end, bottom.itemOffset],
  [95, 100, 1900],
)
console.log('top overscan:', top.start, top.end)
console.log(
  'bottom overscan:',
  bottom.start,
  bottom.end,
  bottom.itemOffset,
)

assert.throws(
  () => calculateVirtualRange(10, 0, 60, 0),
  RangeError,
)
console.log('invalid itemHeight: rejected')

const beforeResize = calculateVirtualRange(20, 25, 50, 125)
const afterResize = calculateVirtualRange(20, 25, 100, 125)
assert.deepEqual(
  [beforeResize.start, beforeResize.end],
  [5, 7],
)
assert.deepEqual(
  [afterResize.start, afterResize.end],
  [5, 9],
)
console.log(
  'resize:',
  `${beforeResize.start}-${beforeResize.end}`,
  `${afterResize.start}-${afterResize.end}`,
)

const beforeFilter = calculateVirtualRange(10, 30, 60, 240, 1)
const afterFilter = calculateVirtualRange(3, 30, 60, 240, 1)
assert.deepEqual(
  [beforeFilter.start, beforeFilter.end],
  [7, 10],
)
assert.deepEqual(
  [afterFilter.start, afterFilter.end],
  [0, 3],
)
console.log(
  'filter:',
  `${beforeFilter.start}-${beforeFilter.end}`,
  `${afterFilter.start}-${afterFilter.end}`,
)

const lowTarget = scrollOffsetForIndex(-4, 10, 30, 60)
const highTarget = scrollOffsetForIndex(99, 10, 30, 60)
const emptyTarget = scrollOffsetForIndex(3, 0, 30, 60)
assert.deepEqual(
  [lowTarget, highTarget, emptyTarget],
  [0, 240, 0],
)
console.log(
  'scrollTo:',
  lowTarget,
  highTarget,
  emptyTarget,
)
empty: 0 0 0
short viewport: 0 1
partial last row: 2 5 60
top overscan: 0 5
bottom overscan: 95 100 1900
invalid itemHeight: rejected
resize: 5-7 5-9
filter: 7-10 0-3
scrollTo: 0 240 0

These tests assert indexes, offsets, and total size instead of relying only on a DOM-node count. That distinction matters because two incorrect ranges can contain the same number of rows.

Same count, different rangeCorrectShifted by one10111213141516111213141516177 rows7 rowsoffset 400offset 440Assert identity and position, not count alone.
Equal row counts do not prove that the rendered indexes are correct.

The resize case calls the pure calculator with the height that a ResizeObserver callback would record. The filter case keeps the old scroll position while reducing the collection length. Clamping converts that stale position into a valid bottom range.

Filtering changes the valid scroll rangeBefore: 10 itemsscroll240filterAfter: 3 itemsnewbottomold position ispast the listClamp to the new maximum before calculating rows.
When filtering shortens the spacer, the old scroll position must be clamped.

Variable Heights, Accessibility, and Focus

Fixed-height virtualization can calculate an item offset with multiplication. Variable-height virtualization has two distinct cases.

Predetermined variable heights are known before the rows render. Examples include sizes stored with the data or produced by a reliable size function. Build a prefix-sum array where each entry contains the combined height of all earlier rows:

prefix[0] = 0
prefix[i + 1] = prefix[i] + height[i]

prefix[itemCount] is the spacer height. A binary search over this array can find the first row whose bottom edge crosses scrollTop. The same search can find the final visible boundary. If a stored height changes, the affected prefix sums must also be updated.

Prefix sums mark every row boundaryrow 0 · height 30row 1 · height 50row 2 · 20row 3 · height 40prefix 0prefix 30prefix 80prefix 100prefix 140scrollTop= 6565 lies between boundaries 30 and 80,so binary search finds row 1.
Prefix sums turn variable row heights into searchable boundary positions.

VueUse accepts an itemHeight(index) or itemWidth(index) callback for known sizes. Its variable-size path determines distances by summing those supplied values. The callback is not automatic DOM measurement.

Dynamically measured heights are unknown until browser layout. Wrapped text, loaded media, and expandable content can create this case. A measurement-based virtualizer observes rendered elements, stores their actual sizes, updates later offsets, and compensates when a changed row sits before the current viewport. TanStack Virtual supports dynamic measurement through measureElement, so it is one option when a fixed or predetermined-size hook no longer fits.

Dynamic heights create a feedback loopbrowser layoutmeasuresize cachecorrectedoffsetspositionedrowsA changed measurement sends correctedpositions around the loop again.
Measured heights feed back into the offsets used for the next layout.

Use native <ul> and <li> semantics for a list when those elements match the content. For widget collections where only part of the set exists in the DOM, aria-posinset gives each rendered item its one-based position and aria-setsize gives the complete set size. The canonical component applies both values to each rendered row.

Focus needs its own policy. A keyboard-focused row can leave the virtual range and be unmounted. The simple adapter does not preserve that focused element. A production component can keep the active row mounted, move focus to a stable container before removal, or implement managed focus with an active item model. The selected behavior must also work when filtering removes the active record.

Focus can leave with an unmounted rowBefore scrollrow 20focusedrow 21row 22scrollAfter scrollrow 21 gonerow 40row 41The data record remains,but its focused DOM element does not.Focus preservation needs an explicit policy.
A focused record may still exist in data after its row element is removed.

The useDynamicList exercise is relevant when insertions and removals must preserve stable record identity.

How to Explain the Tradeoffs in an Interview

A clear explanation starts with the assumptions and follows the calculation:

  • Rows have one fixed height, so division finds the first visible index and multiplication finds every offset.
  • Range calculation has constant cost. Creating the returned slice and rendering it still costs time proportional to the number of visible and overscanned rows.
  • Overscan reduces exposed blank space during fast scrolling but mounts additional nodes.
  • Stable record IDs preserve component identity when data is filtered or reordered.
  • Container resize and source-length changes trigger recalculation and scroll clamping.
  • Predetermined variable sizes require accumulated offsets, with binary search when fast lookup matters.
  • Content-dependent sizes require element measurement and correction after layout.
  • Focus preservation is additional behavior, not a consequence of virtualization itself.

VueUse is suitable when its Vue bindings and known-size model cover the component. A fuller virtualizer such as TanStack Virtual fits measurement or control requirements beyond that model. For structured practice across this implementation and related frontend problems, UIReady Premium Lifetime provides the site's extended study material.

Frequently asked questions

What does useVirtualList do?
useVirtualList renders the visible part of a large collection plus a small buffer. A spacer preserves the collection's full scroll range while rows outside the current range remain unmounted.
Which useVirtualList should I use with Vue?
VueUse provides the Vue useVirtualList API covered in this article. It returns the rendered list, scrollTo, containerProps, and wrapperProps.
Can VueUse useVirtualList handle variable row heights?
Its itemHeight option can be a callback when every row size is known before rendering. It does not automatically measure rendered rows, so content-dependent heights need a measurement-based virtualizer or a separate measurement system.
Why does a virtual list need overscan?
Overscan renders extra rows before and after the visible range. The buffer reduces the chance of exposing a blank area during fast scrolling, at the cost of keeping more DOM nodes mounted.
How should a virtual list expose item positions to assistive technology?
Keep native collection semantics where they fit. When the DOM contains only part of a widget collection, aria-posinset gives each rendered item its one-based position and aria-setsize gives the full collection size.