Vize

virtual-list

Render only the visible slice of a long list (fixed or variable item sizes).

Package @vizejs/composable/virtual-list
Own the source vize lib pull composable:virtual-list
Runtime exports useVirtualList
Gzip budget 4352 B

Minimal setup

Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).

vp install @vizejs/composable

Import the entry directly. Call lifecycle-bound utilities inside <script setup> so cleanup follows the component scope; consult the runtime contract for this entry's SSR and hydration behavior.

import { useVirtualList } from "@vizejs/composable/virtual-list";

Try it

Browse a thousand guides

Keep a long, searchable catalogue responsive by rendering only the rows near its visible area.

Open the interactive example

What to observe: Focus the list and use Page Down, Home or End, or activate Last result. The rendered positions move through 1,000 guides while only a few rows exist at once. Search for 099 to see eleven matches, then try missing for the empty state. Searching returns to the first match without moving focus from the input.

The complete example uses local sample data, not a server request. Each row must occupy exactly the declared 40 CSS pixels, including padding and borders. overscan: 2 keeps two additional rows on either side; the container's 200px height determines the visible area. Bind containerProps to the scroll region and wrapperProps.style to its inner list, then render list rather than the entire source. scrollTo() takes a zero-based position in the filtered list. The first seven rows render on the server; measurement and scroll tracking belong to the component scope. Only currently rendered rows are available to browser Find and screen readers. Position and total-count attributes describe them, but do not make the other rows available: use search to narrow the catalogue, and prefer pagination or a complete list when every item must be readable or contain focusable controls.

Compare the initial and interacted states
Browse a thousand guides: initial state
Initial state
Browse a thousand guides: state after the verified interactions
After the verified interactions

Copy the example

Save this complete SFC in your Vue project. Its public imports and source are the same ones executed in the preview.

<script setup lang="ts">
import { computed, ref, useId, watch } from "vue";
import { useVirtualList } from "@vizejs/composable/virtual-list";

const query = ref("");
const searchId = useId();
const scrollHelpId = useId();
const guides = Array.from({ length: 1_000 }, (_, index) => ({
  id: index + 1,
  title: `Guide ${String(index + 1).padStart(4, "0")}`,
}));
const matches = computed(() =>
  guides.filter((guide) => guide.title.toLowerCase().includes(query.value.trim().toLowerCase())),
);
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(matches, {
  itemSize: 40,
  overscan: 2,
  initialItemCount: 7,
});
// A new search starts at its first result, without moving keyboard focus.
watch(query, () => scrollTo(0), { flush: "post" });
</script>

<template>
  <div class="composable-example">
    <label :for="searchId">Find a guide</label>
    <input :id="searchId" v-model="query" type="search" placeholder="Try 099 or missing" />
    <div class="example-actions">
      <button type="button" :disabled="matches.length === 0" @click="() => scrollTo(0)">
        First result
      </button>
      <button
        type="button"
        :disabled="matches.length === 0"
        @click="() => scrollTo(matches.length - 1)"
      >
        Last result
      </button>
    </div>
    <output aria-live="polite">{{ matches.length }} matching guides</output>
    <p :id="scrollHelpId">Focus the list and use Arrow keys, Page Down, Home or End.</p>
    <div
      v-bind="containerProps"
      role="region"
      aria-label="Guide catalogue"
      :aria-describedby="scrollHelpId"
      tabindex="0"
      class="guide-catalogue"
    >
      <div v-bind="wrapperProps" role="list" aria-label="Matching guides">
        <div
          v-for="row in list"
          :key="row.data.id"
          role="listitem"
          :aria-posinset="row.index + 1"
          :aria-setsize="matches.length"
          class="guide-row"
        >
          {{ row.data.title }}
        </div>
      </div>
    </div>
    <p v-if="matches.length === 0">No guides match. Try a different search.</p>
    <p v-else>
      {{ list.length }} rows rendered · positions {{ (list[0]?.index ?? 0) + 1 }}–{{
        (list.at(-1)?.index ?? 0) + 1
      }}
      of {{ matches.length }}
    </p>
  </div>
</template>

<style scoped>
.guide-catalogue {
  height: 200px;
  border: 1px solid #888;
  border-radius: 6px;
}
.guide-row {
  height: 40px;
  box-sizing: border-box;
  padding: 8px 12px;
  border-bottom: 1px solid #8884;
}
</style>

Runtime contract

Utility Category Stability SSR Hydration Cleanup Targets Host globals Uses
useVirtualList dom experimental deterministic-fallback stable reactive-scope web, desktop globalThis, ResizeObserver useElementRef, useResizeObserver

API

useVirtualList

Render only the visible slice of a long list (fixed or variable item sizes). Offsets are prefix sums recomputed when the items change; the visible range is found by binary search, so scrolling is O(log n). Until the container is measured (always on the server) the first initialItemCount items render, so server and first client render match. The container is measured on scroll and via ResizeObserver, released with the owning reactive scope.

function useVirtualList<Item>( items: MaybeRefOrGetter<readonly Item[]>, options: UseVirtualListOptions<Item>, ): VirtualListControls<Item>

Types

UseVirtualListOptions

Options for useVirtualList.

Member Type Description
itemSize VirtualItemSize<Item> Item size along the scroll axis.
overscan? number Extra items rendered before and after the visible range.
orientation? VirtualListOrientation Scroll axis.
initialItemCount? number Items rendered before the container is measured (every server render). Keeps SSR output deterministic and non-empty for crawlers.
host? MaybeRefOrGetter<ResizeObserverHost | null | undefined> Resize-observer capability used to measure the container.

VirtualListItem

One rendered row/column of useVirtualList.

Member Type Description
data Item Source item.
index number Index in the source list.
offset number Start offset along the scroll axis.
size number Size along the scroll axis.

VirtualContainerStyle

Inline style for the scroll container.

Member Type Description
overflowY "auto" | "hidden" Vertical overflow.
overflowX "auto" | "hidden" Horizontal overflow.

VirtualWrapperStyle

Inline style for the inner wrapper that holds the rendered items.

Member Type Description
width string Wrapper width.
height string Wrapper height.
marginTop string Leading offset (margin-top or margin-left).
marginLeft string Leading offset for horizontal lists.
display "block" | "flex" Horizontal lists lay items out in a row.

VirtualListControls

State and bindings returned by useVirtualList.

Member Type Description
list ComputedRef<readonly VirtualListItem<Item>[]> Items to render.
totalSize ComputedRef<number> Total content size along the scroll axis.
containerProps { readonly ref: ElementRefSetter; readonly onScroll: () => void; readonly style: VirtualContainerStyle; } Bind on the scroll container: v-bind="containerProps".
wrapperProps ComputedRef<{ readonly style: VirtualWrapperStyle }> Bind on the inner wrapper: v-bind="wrapperProps".
scrollTo (index: number) => void Scroll so that the item at index starts at the container edge.