Grid Dynamic
How to build a feature like this
A grid whose rows or columns are not uniform cannot be positioned by arithmetic alone: the engine must know each row's height and each column's width before it can compute offsets, the window, and the scroll extent. There are two ways to supply variable sizes - declared (a function per index) or measured (read back from the DOM with a ResizeObserver). This pattern is the measured one: you render cells with the sizes your content actually needs, tag every cell with its column index, and let the engine discover the real geometry from the mounted window. Rows and columns that have not been visited yet are estimated from default-item-size / default-column-width until they scroll into view and get measured. The trade-off is measurement cost and late corrections against truthful sizes for content the DOM alone knows (wrapped text, loaded fonts, images).
1. Size the scroll box in both axes
Measured grids still use direction="both", so the host element needs a definite width and height - set an explicit height, let the width fill its parent, and add min-height: 0 inside flex/grid parents. Without a constrained viewport there are no scroll events and nothing to virtualize.
2. Choose how sizes are supplied
item-size (row height) and column-width accept the same forms, ordered from least to most flexible: a uniform number, a repeating array, a per-index function, or dynamic - pass 0, null, or nothing to switch that axis to measurement. Functions receive (item, index) for rows and (index) for columns, and must return the size in px the slot will actually render.
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.
// item-size (rows) and column-width (columns) accept the same four forms.
// Choose the form that best matches your content - the engine positions
// from it, no DOM measurement needed:
// number uniform size -> pure arithmetic, no storage
// number[] repeating pattern -> cycles per index
// function per-index size -> rows: (item, index); cols: (index)
// 0/null/omit dynamic, measured -> default-item-size / default-column-width
// seed the pre-measure estimate
const uniform = 48;
const pattern = [ 100, 180, 260 ];
const perRow = (item: Note) => 24 + item.lines.length * 20;
const perCol = (index: number) => (index === 0 ? 300 : [ 100, 150 ][ (index - 1) % 2 ]!);3. Tag cells, render content-sized rows, and let the engine measure
With both size props unset, the engine measures what it mounts. Row heights come from the rendered row box, so a row grows with its tallest cell - let wrapped text, images, or explicit cell heights define it. Column widths come from the cells, and to attribute a measured box to a column the engine scans the mounted row for elements carrying data-col-index (a plain attribute whose value is the absolute column index). That makes column detection independent of your slot structure: the tag can sit on the cell itself or on a wrapper, one level deep or nested. Give cells their intended rendered width (inline-size or CSS) - the engine reads the real box, so what you render is what gets stored. Before the first window is measured, default-item-size and default-column-width seed the estimates used for the initial range, scrollbar, and total size.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
// 1-3 wrapped text lines per row: the real height is only known after layout.
interface Note { id: number; who: string; lines: string[]; }
const WORDS = [ 'amber', 'basalt', 'cobalt', 'dune', 'ember', 'fjord' ];
const notes: Note[] = Array.from({ length: 4_000 }, (_, id) => ({
id,
who: [ 'Ada', 'Grace', 'Linus', 'Guido' ][ id % 4 ]!,
lines: Array.from({ length: 1 + (id % 3) }, (_, line) =>
`${ WORDS[ (id * 13 + line * 7) % WORDS.length ] } `.repeat(9 + (id * 7 + line) % 12).trim()),
}));
// The SLOT decides the rendered sizes; the engine MEASURES them, so no
// item-size / column-width prop is passed. The default-* props only seed the
// pre-measure estimate (first range, scrollbar) before the first paint.
const columnCount = 4;
const widths = [ 72, 120, 420, 130 ]; // rendered cell widths (px)
const cellStyle = (col: number) => ({ inlineSize: `${ widths[ col ] }px` });
const cellText = (note: Note, col: number) =>
col === 0 ? String(note.id) : col === 1 ? note.who
: [ 'Backlog', 'Active', 'Review', 'Done' ][ (note.id + col) % 4 ]!;
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="board"
direction="both"
:items="notes"
:column-count="columnCount"
:default-item-size="120"
:default-column-width="140"
aria-label="Note board grid"
>
<template #item="{ item, columnRange, getCellAriaProps }">
<div class="grid-row">
<div
v-for="c in columnRange.end - columnRange.start"
:key="columnRange.start + c - 1"
:data-col-index="columnRange.start + c - 1"
class="grid-cell"
:style="cellStyle(columnRange.start + c - 1)"
v-bind="getCellAriaProps(columnRange.start + c - 1)"
>
<template v-if="columnRange.start + c - 1 === 2">
<p v-for="(line, i) in item.lines" :key="i" class="line">{{ line }}</p>
</template>
<template v-else>{{ cellText(item, columnRange.start + c - 1) }}</template>
</div>
</div>
</template>
</VirtualScroll>
</template>
<style scoped>
.board {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
} /* definite 2-D viewport */
.grid-row {
display: flex;
align-items: stretch;
} /* rows stretch to the tallest cell */
.grid-cell {
box-sizing: border-box;
flex: none;
padding: 8px;
overflow: hidden;
border-right: 1px solid oklch(50% 0 0 / 0.1);
border-bottom: 1px solid oklch(50% 0 0 / 0.1);
}
.line {
margin: 0;
font-size: 12px;
line-height: 18px;
}
</style>4. Re-measurement is automatic - keep it that way
Every newly mounted row and cell is observed, so scrolling, buffer changes, or container resizes that bring new content into the window extend the measurements on the fly; a size change above the current viewport shifts the content end, and the engine corrects the scroll position so the user does not jump. When a measured box grows after mount (late font, image load), the observer picks it up and the layout self-corrects - reserve space for media to avoid churn. If a dataset replacement or external style change invalidates the cached geometry, call the exposed refresh() to reset all cached measurements and re-initialize sizes from the current props and defaults; already-mounted rows and cells are then measured again as their boxes change. Declared (function) sizes skip this whole feedback loop: prefer them whenever the sizes are known before render, and keep the DOM in agreement with what the function returns.
- 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