Grid Fixed
How to build a feature like this
A two-dimensional grid - rows of equal height, columns of declared width - needs two virtual windows at once: one that picks which rows are mounted along the vertical axis, and one that picks which columns each mounted row renders along the horizontal axis. Because both axes have known sizes (a numeric and column-width forms you use), row placement is pure arithmetic with a numeric item-size, and the DOM stays bounded to roughly row-window × column-window cells instead of rows × columns. The trade-off is the contract: all rows share one height and column widths must be expressible as numbers - when content decides sizes, use the measured (dynamic) grid instead.
1. Give the scroll box a definite size in both axes
With direction="both", <VirtualScroll> renders a scrollable host that pans horizontally and vertically. Virtualization needs a known viewport, so the host must be constrained in both dimensions: give it an explicit height (the width fills its parent) and let overflow scroll the content that extends past either edge. In flex/grid layouts, remember min-height: 0 on the list so it can shrink below its content instead of growing forever.
2. Model the rows, then declare the column geometry
The row axis is your data: items is an array with one entry per row. Columns are not data - they are a declared grid: column-count sets how many columns exist in total (it drives the horizontal scroll extent and the column-window clamp), while column-width provides the width in px for each column. You can pass one number for a uniform width, an array that cycles as a repeating pattern over the column indices, or a function of the column index. A numeric item-size declares the uniform row height; both sizes are contracts - every rendered cell must match them, borders and padding included (box-sizing: border-box).
3. Render one windowed row per item
The #item slot is invoked once per mounted row and receives the row's item and index plus the current columnRange - { start, end } with an exclusive end - and two helpers: getColumnWidth(colIndex) returns the declared width of any column, and getCellAriaProps(colIndex) returns the ARIA attributes for a cell. Loop over the range and emit one cell per column, sizing each cell's width from getColumnWidth and binding the aria props. The engine translates each row so its content starts at the first visible column and already accounts for the skipped columns (columnRange.padStart/padEnd), so the slot must not add its own horizontal offsets - lay the cells out flush (flex row) and make the row fill its wrapper, which is exactly item-size tall. In both mode the row wrapper carries the row role and aria-rowindex automatically.
The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
// A grid item is ONE ROW of data; cells are looked up from it per column.
interface Row { id: number; sku: string; name: string; }
const rows: Row[] = Array.from({ length: 10_000 }, (_, id) => ({
id, sku: `SKU-${ id }`, name: `Product ${ id }`,
}));
// Column geometry is declared, not data: 80 columns whose widths repeat the
// [100, 180, 260] pattern. Row placement is arithmetic (O(1)).
const columnCount = 80;
const itemSize = 44; // uniform row height (px)
const columnWidth = [ 100, 180, 260 ]; // cycles over columns
function cellText(row: Row, col: number): string {
if (col === 0) return String(row.id);
if (col === 1) return row.sku;
if (col === 2) return row.name;
return `metric ${ row.id * 7 + col }`;
}
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="grid"
direction="both"
:items="rows"
:item-size="itemSize"
:column-count="columnCount"
:column-width="columnWidth"
aria-label="Data grid"
>
<template #item="{ item, columnRange, getColumnWidth, getCellAriaProps }">
<div class="grid-row">
<div
v-for="c in columnRange.end - columnRange.start"
:key="columnRange.start + c - 1"
class="grid-cell"
:style="{ inlineSize: `${ getColumnWidth(columnRange.start + c - 1) }px` }"
v-bind="getCellAriaProps(columnRange.start + c - 1)"
>
{{ cellText(item, columnRange.start + c - 1) }}
</div>
</div>
</template>
</VirtualScroll>
</template>
<style scoped>
.grid {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
} /* definite 2-D viewport */
/* Each row wrapper is exactly item-size tall; the row must fill it. */
.grid-row {
display: flex;
align-items: stretch;
height: 100%;
}
/* Cells are laid out by you, sized by the engine's width oracle. */
.grid-cell {
box-sizing: border-box;
flex: none;
display: flex;
align-items: center;
padding-inline: 0.5rem;
overflow: hidden;
white-space: nowrap;
border-right: 1px solid oklch(50% 0 0 / 0.1);
border-bottom: 1px solid oklch(50% 0 0 / 0.1);
}
</style>4. Skip the data objects when cells are pure coordinates
Uniform grids often have no payload: cell content is derived from the (row, column) coordinates (indices, metrics, formulas), so materializing a million row objects buys nothing. Pass a sparse array of the right length - new Array(n) - and render from the slot's index; only the windowed indices are ever read from items, so memory stays flat. Keep the .grid-row / .grid-cell styles from the full example above; they are all this variant needs.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
// Index-only variant: cells are pure functions of (row, column) coordinates,
// so the dataset never materializes row objects. A sparse array of the right
// length is enough - only the windowed indices are ever touched.
const rows = new Array(10_000_000);
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="grid"
direction="both"
:items="rows"
:item-size="48"
:column-count="100"
:column-width="[ 120, 160 ]"
>
<template #item="{ index, columnRange, getColumnWidth, getCellAriaProps }">
<div class="grid-row">
<div
v-for="c in columnRange.end - columnRange.start"
:key="columnRange.start + c - 1"
class="grid-cell"
:style="{ inlineSize: `${ getColumnWidth(columnRange.start + c - 1) }px` }"
v-bind="getCellAriaProps(columnRange.start + c - 1)"
>
R{{ index }} x C{{ columnRange.start + c - 1 }}
</div>
</div>
</template>
</VirtualScroll>
</template>5. Overscan both windows, not just one
buffer-before / buffer-after (default 5) keep rows mounted past each viewport edge so fast panning does not flash blanks while rows mount; they count rows, not pixels. The column window keeps its own small built-in overscan on each side, so horizontal panning is covered too. Watch the cost model: the DOM holds roughly (visible rows + buffers) × (visible columns + column overscan) cells, so make the buffers large enough to hide mounting latency but no larger - every extra buffered row multiplies the cell count of the whole window.
- Scroll Status
- Directionboth
- Current Item #- ×
- Rendered Range #0:0
- DOM Items #—
- Total Size (px)0w ×0h
- Viewport Size (px)0w ×0h
- Scroll Offset (px)0x ×0y
- Controls