8.8 KiB
VirtualGrid
2D windowing primitive. Virtualises rows and columns — renders only the cells inside the visible rectangle plus row / column overscan. Keeps DOM size constant regardless of rowCount × columnCount. A 1000 × 1000 grid (1M cells) renders ~100 DOM nodes, not a million.
Scope is fixed-size per axis (rowSize + columnSize). Dynamic per-cell sizing in 2D is genuinely ambiguous — a measured cell tells you about its row AND column at the same time, and the delta has to be attributed somehow. Most virtualisers sidestep this by forcing fixed axis sizes. Follow the same convention.
Anatomy
<VirtualGrid.Provider
rowCount={1000}
columnCount={50}
rowSize={32}
columnSize={120}
>
{#snippet children({ virtualCells, totalHeight, totalWidth })}
<VirtualGrid.Viewport style:height="500px" style:width="100%">
<div style:height="{totalHeight}px" style:width="{totalWidth}px" style:position="relative">
{#each virtualCells as cell (cell.key)}
<VirtualGrid.Cell {...cell}>
{data[cell.rowIndex][cell.columnIndex]}
</VirtualGrid.Cell>
{/each}
</div>
</VirtualGrid.Viewport>
{/snippet}
</VirtualGrid.Provider>
Parts
| Part | Element | Description |
|---|---|---|
Provider |
<div> |
Root context. Tracks both scroll axes, viewport size, 2D range derivation. |
Viewport |
<div> |
2D scroll container (overflow: auto on both axes). |
Cell |
<div> |
One virtualised cell. Absolutely positioned via translate3d(col, row, 0). |
Props
Provider
| Prop | Type | Default | Description |
|---|---|---|---|
rowCount |
number |
required | Total number of rows. |
columnCount |
number |
required | Total number of columns. |
rowSize |
number |
40 |
Fixed row height (px). |
columnSize |
number |
120 |
Fixed column width (px). |
rowOverscan |
number |
2 |
Extra rows above / below the visible range. |
columnOverscan |
number |
2 |
Extra columns left / right of the visible range. |
getRowKey |
(index: number) => string | number |
(i) => i |
Stable key per row. |
getColumnKey |
(index: number) => string | number |
(i) => i |
Stable key per column. |
Cell
Pass every field of the virtualCell snippet value through to the Cell — rowIndex, columnIndex, rowStart, columnStart, rowSize, columnSize.
Snippet props
{
virtualCells: Array<{
rowIndex: number;
columnIndex: number;
rowStart: number;
columnStart: number;
rowSize: number;
columnSize: number;
key: string | number;
}>;
totalHeight: number; // sum of row sizes
totalWidth: number; // sum of column sizes
isEmpty: boolean;
scrollToCell: (
rowIndex: number,
columnIndex: number,
opts?: {
rowAlign?: 'start' | 'center' | 'end' | 'auto';
columnAlign?: 'start' | 'center' | 'end' | 'auto';
behavior?: ScrollBehavior;
}
) => void;
}
Data Attributes
| Part | Attribute | Values |
|---|---|---|
| Provider | data-virtual-grid |
Always present |
| Viewport | data-virtual-grid-viewport |
Always present |
| Cell | data-virtual-grid-cell |
Always present |
| Cell | data-row-index |
Row index |
| Cell | data-column-index |
Column index |
ARIA
VirtualGrid is a rendering optimisation; it carries no ARIA roles. For a semantic table / grid, wrap the Provider in the appropriate outer element (e.g. role="grid") and set role="row" / role="gridcell" on Cell yourself.
Sema events
| Event | Family | Verb | Target | Intent | Emitted? | When |
|---|---|---|---|---|---|---|
handle-scroll-row |
handle |
scroll |
viewport |
— | NO | Row-axis scroll. Same buzz reasoning as virtual-list — soma does NOT emit. |
handle-scroll-column |
handle |
scroll |
viewport |
— | NO | Column-axis scroll. Same as above. |
shift-navigate-to-cell |
shift |
navigate |
viewport |
— | YES | scrollToCell(rowIndex, columnIndex) — programmatic discrete navigation. |
commit-set-resize |
commit |
set |
provider |
neutral |
YES | rowCount or columnCount changed (real dimension change, not initial). |
See virtual-list README for the rationale on skipping handle-scroll* emission. No per-component pack — falls back to family bases.
Comparison
| Feature | Soma | Ark UI | TanStack | React Aria |
|---|---|---|---|---|
| Dedicated 2D component | ✅ | ❌ | ✅ | ⚠️ |
| Fixed row / column sizes | ✅ | — | ✅ | ✅ |
| Dynamic row heights | ❌¹ | — | ✅ | ⚠️ |
| Dynamic column widths | ❌¹ | — | ✅ | ⚠️ |
| Row + column overscan | ✅ | — | ✅ | ✅ |
scrollToCell(row, col, align) |
✅ | — | ✅ | ✅ |
| Independent getRowKey / getColumnKey | ✅ | — | ✅ | ✅ |
| ARIA-agnostic | ✅ | — | ✅ | ❌ |
| Zero external dependencies | ✅ | — | ✅ | ❌ |
¹ Fixed-size only. See notes at the top — dynamic 2D is ambiguous enough that most virtualisers don't offer it. TanStack does, at the cost of complex axis-delta attribution. Not planned.
Usage
100 × 100 spreadsheet
<script lang="ts">
import { VirtualGrid } from '$soma/components';
const rows = 100;
const cols = 100;
const cell = (r: number, c: number) => `R${r}·C${c}`;
</script>
<VirtualGrid.Provider
rowCount={rows}
columnCount={cols}
rowSize={32}
columnSize={100}
>
{#snippet children({ virtualCells, totalHeight, totalWidth })}
<VirtualGrid.Viewport style:height="400px" style:width="600px">
<div style:height="{totalHeight}px" style:width="{totalWidth}px" style:position="relative">
{#each virtualCells as c (c.key)}
<VirtualGrid.Cell {...c}>
{cell(c.rowIndex, c.columnIndex)}
</VirtualGrid.Cell>
{/each}
</div>
</VirtualGrid.Viewport>
{/snippet}
</VirtualGrid.Provider>
Thumbnail grid with scrollToCell
<button onclick={() => api?.(500, 3, { rowAlign: 'center' })}>
Jump to image 500-3
</button>
<VirtualGrid.Provider rowCount={1000} columnCount={5} rowSize={180} columnSize={200}>
{#snippet children({ virtualCells, totalHeight, totalWidth, scrollToCell })}
{(() => { api = scrollToCell; return ''; })()}
<VirtualGrid.Viewport style:height="600px">
<div style:height="{totalHeight}px" style:width="{totalWidth}px" style:position="relative">
{#each virtualCells as c (c.key)}
<VirtualGrid.Cell {...c}>
<img src={images[c.rowIndex * 5 + c.columnIndex]} alt="" />
</VirtualGrid.Cell>
{/each}
</div>
</VirtualGrid.Viewport>
{/snippet}
</VirtualGrid.Provider>
Implementation notes
- Total size is
rowCount × rowSize×columnCount × columnSize— the inner canvas sets its height/width to these values so theViewport's native scrollbar reflects the real scrollable area. - Visible range is computed per axis independently (two 1D problems). The cartesian product of visible rows × visible columns gives the cells to render.
- No ResizeObserver on Cell — fixed sizes mean we never need to measure. That's the main reason VirtualGrid skips dynamic — measuring would change the axes non-orthogonally.
- One
Viewportonly — unlike VirtualList, there's no window-scroll variant for VirtualGrid. 2D window-scroll is niche and the scroll semantics are confusing.