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.

Open the live example: Headless virtualization (Headless)

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.

<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:

<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:

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:

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.

<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.

<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

Related articles

  • SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
  • SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
  • Building a Log Viewer for Large Logs in Svelte - How to build a production-ready log viewer with SvGrid - virtualization for millions of lines, severity coloring, live tailing with scroll-lock, and server-side filtering.