<!-- https://svgrid.com/docs/help/headless/virtualization/ - SvGrid documentation as markdown. Index of every page: https://svgrid.com/llms.txt -->

# Headless virtualization

`<SvGrid>` virtualizes rows and columns for you. When you render your own
markup, you virtualize yourself with the same engine SvGrid uses -
`createSvelteVirtualizer` (reactive) or `createVirtualizer` (framework-agnostic).
Only the rows in view get DOM nodes, so 100k rows stay smooth.

> Live demo: Headless virtualization - https://svgrid.com/demos/187-headless-virtual/

## The idea

A virtualizer answers two questions as the user scrolls: **which items are
visible**, and **how far to offset them**. You give it the item `count`, an
`estimateSize` (row height), and the `viewportHeight`; it gives you the virtual
items to render plus the total size to reserve. You feed it the live scroll
position with `setScrollOffset`, and read `version` so your derived values
recompute.

```svelte
<script lang="ts">
  import {
    createSvGrid,
    createCoreRowModel,
    createSvelteVirtualizer,
    tableFeatures,
    type ColumnDef,
  } from '@svgrid/grid/core'

  type Row = { id: number; name: string; score: number }
  const data: Row[] = Array.from({ length: 100_000 }, (_, i) => ({
    id: i, name: `Item ${i}`, score: (i * 37) % 1000,
  }))
  const features = tableFeatures({})   // core only - nothing to sort or filter
  const columns: ColumnDef<typeof features, Row>[] = [
    { field: 'name', header: 'Name' },
    { field: 'score', header: 'Score' },
  ]

  const table = createSvGrid({
    _features: features,
    _rowModels: { coreRowModel: createCoreRowModel<Row>() },
    data, columns,
  })
  const rows = table.getRowModel().rows

  const ROW_H = 34
  const VIEWPORT_H = 420
  const virtualizer = createSvelteVirtualizer({
    count: rows.length,       // a number (call setOptions({ count }) when it changes)
    estimateSize: ROW_H,      // number, or (index) => px for variable heights
    overscan: 8,
    viewportHeight: VIEWPORT_H,
  })

  // Reading `version` makes these recompute whenever the virtualizer updates.
  const items = $derived.by(() => { virtualizer.version; return virtualizer.getVirtualItems() })
  const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })

  const onScroll = (e: Event) =>
    virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)
</script>
```

## Render only what's visible

Reserve the full scroll height with a spacer, then absolutely-position each
visible row at its `start` offset:

```svelte
<div style={`height: ${VIEWPORT_H}px; overflow: auto; position: relative;`} onscroll={onScroll}>
  <!-- Spacer reserves the full height so the scrollbar is correct. -->
  <div style={`height: ${totalSize}px; position: relative;`}>
    {#each items as vi (vi.key)}
      {@const row = rows[vi.index].original as Row}
      <div style={`position: absolute; top: 0; left: 0; width: 100%;
                   height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>
        {row.name} - {row.score}
      </div>
    {/each}
  </div>
</div>
```

`getVirtualItems()` returns only the ~20 rows in view (plus `overscan`), each
with `index`, `start` (px offset), `size`, and `key`. As the user scrolls,
`setScrollOffset` bumps `version` and the list re-computes; the other 99,980
rows never touch the DOM.

## Columns too

For very wide grids, `createColumnVirtualizer` does the same across leaf columns
(horizontal). Drive it with `setScrollOffset(scrollLeft)` on the container's
horizontal scroll:

```ts
const colVirtualizer = createColumnVirtualizer({
  count: leafColumns.length,
  viewportWidth: containerWidth,
  estimateSize: (i) => leafColumns[i].getSize(),
  overscan: 3,
})
```

## Moving the window less often

Two options shape how the window follows a scroll. `overscanBehind` keeps
fewer items on the side the scroll is leaving, while the full `overscan`
stays ahead of it. `overscanMin` keeps the window where it is while at least
that many items are still rendered ahead of the scroll, and moves it only
when the scroll runs past them, with the full `overscan` ahead again:

```ts
const colVirtualizer = createColumnVirtualizer({
  count: leafColumns.length,
  viewportWidth: containerWidth,
  estimateSize: (i) => leafColumns[i].getSize(),
  overscan: 5,        // ahead of the scroll right after a move
  overscanBehind: 1,  // behind it
  overscanMin: 2,     // never fewer ahead; the window moves 4 items at a time
})
```

Moving a column window touches every rendered row, about the same work
whether one column enters or four, so a few larger moves cost less than many
one-column moves, and the scroll frames between moves change nothing.
`<SvGrid>` runs its column window this way. A window built at rest, or after
a jump to items outside the rendered window, carries `overscanMin`.

While the window holds, `version` does not change and `getState().scrollOffset`
keeps the offset of the last move. Read the scroll position from your
container if something else has to follow it every frame.

## Which one to use

| Export | Use when |
| --- | --- |
| `createSvelteVirtualizer` | Inside a Svelte component - exposes a reactive `version` to re-derive from. |
| `createVirtualizer` | Framework-agnostic - a non-Svelte custom layer / worker (subscribe manually). |
| `createColumnVirtualizer` | Horizontal (column) virtualization. |

`VirtualItem`, `VirtualizerOptions`, and `VirtualizerState` are exported for
type annotations.

## It is already on

Row virtualization is the default, which is why a large grid needs no
configuration to stay responsive. The DOM holds the visible window, not the row
count - open the inspector on the grid below and you will find a few dozen rows,
not five thousand.

```svelte
<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'

  type Row = { id: number; label: string; n: number }

  const many: Row[] = Array.from({ length: 5000 }, (_, i) => ({
    id: i,
    label: 'Row ' + i,
    n: (i * 37) % 1000,
  }))

  const columns: GridColumns<Row> = [
    { field: 'label', header: 'Label', width: 200 },
    { field: 'n',     header: 'Value', width: 120 },
  ]
</script>

<SvGrid data={many} {columns} sortable />
```

## Columns need their own opt-in

Row virtualization still renders every column of every visible row, so a wide
grid stays slow until you add `columnVirtualization`. The trade is real:
off-screen columns are not in the DOM, so anything that walks the row's cells -
a stylesheet selecting the nth child, a screenshot test - sees only the window.

```svelte
<script lang="ts">
  import { SvGrid, type GridColumns } from '@svgrid/grid'

  type Wide = Record<string, number>

  const names = Array.from({ length: 60 }, (_, i) => 'c' + i)

  const wide: Wide[] = Array.from({ length: 2000 }, (_, r) => {
    const row: Wide = { id: r }
    names.forEach((name, c) => (row[name] = (r * (c + 1)) % 500))
    return row
  })

  const columns: GridColumns<Wide> = names.map((name) => ({
    field: name,
    header: name.toUpperCase(),
    width: 90,
  }))
</script>

<SvGrid data={wide} {columns} columnVirtualization />
```

## See also

- [Build a table from scratch](https://svgrid.com/docs/help/headless/build-a-table/) - render non-virtualized first
- [Row models](https://svgrid.com/docs/help/headless/row-models/)
- [Benchmarks](https://svgrid.com/docs/help/benchmarks/) - the 1M-row numbers

---

SvGrid is the Svelte 5 data grid (`npm install @svgrid/grid`, MIT). This page: https://svgrid.com/docs/help/headless/virtualization/ . All docs: https://svgrid.com/llms.txt
