Vertical Fixed
How to build a feature like this
To virtualize a vertical list whose rows all share one height you declare only a few things: a scroll container with a definite height, an items array, and a numeric item-size. With a uniform size the engine derives every row position from index × item-size, so computing the visible window on each scroll is O(1) and only the window - plus a small overscan buffer - is ever mounted, no matter whether the list has 1,000 or millions of rows. Rows may carry real data objects or be pure index placeholders; both models are shown below.
1. Give the scroll container a definite height
<VirtualScroll> renders its own scrollable host element, so your CSS must give that box a definite size along the scroll axis: a fixed height, a viewport-relative one (e.g. h-dvh), or a flex/grid fill inside a sized parent. If the box is unconstrained it grows with its content and never scrolls - there is no viewport to serve. Inside a flex column parent add min-height: 0: flex items default to min-height: auto and refuse to shrink below their content.
/* The scroll box needs a definite height from your layout - fixed
(480px), viewport-based, or flex fill with min-height: 0. */
.virtual-list {
height: 480px;
}2. Provide the data: real item objects, or index-only placeholders
The common case is a real array of your records - API results, store state, computed values. VirtualScroll only needs the array; its length is the row count. The #item slot decides what each row renders and receives the payload as item together with index.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
// Real rows - typical case: objects fetched from an API or held in state.
// The list reads only the length; the slot decides what to render per row.
const items = Array.from({ length: 10_000 }, (_, i) => ({
id: i,
title: `Item ${ i }`,
}));
</script>
<template>
<VirtualScroll
class="virtual-list"
:items="items"
:item-size="50"
aria-label="Fixed-height list"
virtual-scrollbar
>
<!-- Scoped slot receives the row payload and its index. -->
<template #item="{ item, index }">
<div class="row">
<span class="row-index">#{{ index }}</span>
<strong>{{ item.title }}</strong>
</div>
</template>
</VirtualScroll>
</template>
<style scoped>
/* The scroll viewport needs a definite height (480px here) - without it the
container cannot scroll and virtualization has no viewport to serve. */
.virtual-list {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
}
/* Each row wrapper is exactly item-size (50px) tall; the inner div must fill
it with border-box sizing so borders do not add to the measured height. */
.row {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.75rem;
height: 100%;
padding-inline: 1rem;
border-bottom: 1px solid oklch(50% 0 0 / 0.1);
}
.row-index {
font-size: 0.75rem;
opacity: 0.5;
}
</style> The payload is optional. When a row's content derives entirely from its index - numbered rows, patterns, skeleton placeholders - you can skip the data objects and pass a sparse array of the right length: new Array(count). The engine accesses only the length and the visible window, so holes are never materialized and memory stays flat however long the list is. With a sparse array item is undefined, so render from index. Type the component with the generic (e.g. <VirtualScroll<Row>>) when your slot reads typed fields.
<script setup lang="ts">
// Alternative - index-only rows: nothing is rendered from the payload, so no
// objects are needed. A sparse array of the right length costs nothing: the
// list only accesses the visible window, so memory stays O(1) no matter how
// long the list is (uniform sizes keep the math purely arithmetic).
const items = new Array(1_000_000);
</script>
<template>
<VirtualScroll
class="virtual-list"
:items="items"
:item-size="50"
virtual-scrollbar
>
<!-- item is undefined for sparse holes; render from the index. -->
<template #item="{ index }">
<div class="row">Row #{{ index }}</div>
</template>
</VirtualScroll>
</template>3. Declare the uniform size and overscan, and fill the row box
item-size is a contract, not a hint: the engine sizes every row wrapper to exactly this value and derives total scroll size, range math, and scrollbar geometry from it. Your slot root must therefore fill that box - height: 100% with box-sizing: border-box so borders and padding do not add to the measured size (see .row above). Because omitting item-size switches the axis to measured (dynamic) mode, declare the numeric size whenever your rows really are uniform - and use a value that matches your CSS row height. Uniform sizes are one option - if rows vary you would pass a size function or repeating pattern array instead, or 0/null to measure dynamically (covered by the dynamic example pages).
The boolean virtual-scrollbar draws the built-in overlay bars instead of the native ones; besides consistent cross-browser styling it is a performance improvement - the bars are driven by the engine's own scroll math, so their rendering cost stays flat no matter how long the list grows. It is also turned on automatically once content exceeds the browser's ~10M px limit.
buffer-before / buffer-after (default 5) keep extra rows mounted past each edge of the viewport so fast scrolling or inertia never flashes empty space while new rows mount. They count rows, not pixels - raise them when rows are expensive to render (images, complex layouts) and keep them low otherwise, because every buffered row is real DOM.
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- DOM Items #—
- Total Size (px)0h
- Viewport Size (px)0h
- Scroll Offset (px)0y
- Controls