Grid Fixed

Bidirectional scrolling with uniform dimensions
Simultaneously virtualizes 1,000 rows and 100 columns. Uses fixed itemSize (80px) and alternating columnWidth values. Panning in any direction maintains high performance.

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.

R0 × C0
100px
R0 × C1
150px
R1 × C0
100px
R1 × C1
150px
R2 × C0
100px
R2 × C1
150px
R3 × C0
100px
R3 × C1
150px
R4 × C0
100px
R4 × C1
150px
  • Scroll Status
  • Direction
    both
  • Current Item #
    - ×
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y
  • Controls