Headless virtualization
50,000 rows, headless. createSvelteVirtualizer reports the visible slice; the markup is hand-written in a custom scroll container.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Headless). See the SvGrid documentation for the full API.
About this example
Headless virtualization in Svelte 5: createSvelteVirtualizer from @svgrid/grid reports which of 50,000 rows are in view and where to place them, and this component renders those rows into its own scroll container with hand-written markup. Only about twenty rows hold DOM nodes at any time, so scrolling stays smooth without the SvGrid render component.
The createSvelteVirtualizer engine tells you which rows are in view and where to put them; you render your own scroll container. Only ~20 rows get DOM nodes at a time, so 50,000 rows scroll smoothly. No <SvGrid responsive={true}>.
Imports, features and API used
Imports: @svgrid/grid
Columns: name (Name), city (City), score (Score)
Frequently asked questions
How do I virtualize my own list or table in Svelte 5?
Call createSvelteVirtualizer with count, estimateSize (the row height), overscan and viewportHeight, then render virtualizer.getVirtualItems() at the offsets each item reports inside a scroll container. Reading virtualizer.version in a $derived makes the visible slice recompute on scroll.
Does the virtualizer require the SvGrid component?
No. It is the same engine the grid uses internally, exported on its own, so it works with any markup: a table, a list of cards or a custom layout.
How many rows are in the DOM at once?
Roughly the visible count plus overscan, about twenty here. The other 49,980 rows exist only as data until they scroll into view.
Related documentation
Related articles
- svelte-headless-table and the Svelte 5 upgrade: your three options - svelte-headless-table has not shipped since October 2024 and declares svelte@^4. Here are the three real paths off it - the maintained fork, TanStack Table v9, or a rendered grid - and how to tell which one is yours.
- 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.
- First Light - Pointing SvGrid at 100,000 Rows - When we first fed SvGrid a hundred thousand rows, we were testing more than performance - we were testing whether building native on Svelte 5 runes was the right call from the start.
Source code (187-headless-virtual.svelte)
<!-- Documented in: docs/help/headless/virtualization.md -->
<script lang="ts">
/**
* 187. Headless virtualization - render 50k rows yourself
* -------------------------------------------------------
* The `createSvelteVirtualizer` engine tells you which rows are in view and
* where to put them; you render your own scroll container. Only ~20 rows get
* DOM nodes at a time, so 50,000 rows scroll smoothly. No <SvGrid responsive={true}>.
*/
import {
createSvGrid,
createCoreRowModel,
createSvelteVirtualizer,
tableFeatures,
type GridColumns,
} from '@svgrid/grid'
type Row = { id: number; name: string; city: string; score: number }
const CITIES = ['London', 'Berlin', 'Tokyo', 'Austin', 'Oslo', 'Lisbon', 'Denver']
const data: Row[] = Array.from({ length: 50_000 }, (_, i) => ({
id: i,
name: `Account ${i.toString().padStart(5, '0')}`,
city: CITIES[(i * 7) % CITIES.length]!,
score: (i * 37) % 1000,
}))
const features = tableFeatures({})
const columns: GridColumns<Row> = [
{ field: 'name', header: 'Name' },
{ field: 'city', header: 'City' },
{ 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 = 380
const virtualizer = createSvelteVirtualizer({
count: rows.length,
estimateSize: ROW_H,
overscan: 8,
viewportHeight: VIEWPORT_H,
})
// Read `version` so these recompute whenever the virtualizer updates.
const items = $derived.by(() => {
virtualizer.version
return virtualizer.getVirtualItems()
})
const totalSize = $derived.by(() => {
virtualizer.version
return virtualizer.getTotalSize()
})
function onScroll(e: Event) {
virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)
}
</script>
<section class="hv-wrap">
<p class="hv-note">
<strong>50,000 rows</strong>, headless. <code>createSvelteVirtualizer</code>
reports the visible slice; the markup below is hand-written. Scroll - only the
~{items.length} rows in view exist in the DOM.
</p>
<div class="hv-scroll" style={`height: ${VIEWPORT_H}px;`} onscroll={onScroll}>
<!-- Spacer reserves the full height so the scrollbar is correct. -->
<div class="hv-spacer" style={`height: ${totalSize}px;`}>
{#each items as vi (vi.key)}
{@const row = rows[vi.index].original as Row}
<div class="hv-row" style={`height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>
<span class="hv-i">{vi.index}</span>
<span class="hv-name">{row.name}</span>
<span class="hv-city">{row.city}</span>
<span class="hv-score">{row.score}</span>
</div>
{/each}
</div>
</div>
<p class="hv-count">{rows.length.toLocaleString()} rows ยท {items.length} rendered</p>
</section>
<style>
.hv-wrap { display: flex; flex-direction: column; gap: 12px; padding: 4px; }
.hv-note { font-size: 13px; color: var(--sg-fg, #0f172a); margin: 0; }
.hv-note strong { color: var(--sg-accent, #6366f1); }
.hv-scroll {
overflow: auto; position: relative;
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
background: var(--sg-bg, #fff);
}
.hv-spacer { position: relative; width: 100%; }
.hv-row {
position: absolute; top: 0; left: 0; width: 100%;
display: grid; grid-template-columns: 70px 1fr 120px 80px; align-items: center;
padding: 0 14px; box-sizing: border-box; font-size: 13px;
border-bottom: 1px solid var(--sg-border, #eef2f7);
color: var(--sg-fg, #0f172a);
}
.hv-i { color: var(--sg-muted, #94a3b8); font-variant-numeric: tabular-nums; font-size: 11px; }
.hv-name { font-weight: 500; }
.hv-city {
justify-self: start; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px;
background: color-mix(in oklab, var(--sg-accent, #6366f1) 12%, transparent);
color: var(--sg-accent, #6366f1);
}
.hv-score { text-align: right; font-variant-numeric: tabular-nums; }
.hv-count { font-size: 11px; color: var(--sg-muted, #64748b); margin: 0; }
</style>More Headless examples
- Headless -> your own table - No <SvGrid>: the createSvGrid engine sorts + filters, and this component renders a plain, hand-styled <table>. The engine does the logic; you own the markup.
- Styling a headless table - You own every pixel. Same engine, three looks - flip preset (minimal / bordered / card), density, and zebra striping. --sg-* tokens keep it in sync with the site theme.
- Two grids, one shared state - createGridState returns a [get, set] tuple - a reactive store you own. Feed it to two createSvGrid engines and they stay in lockstep.
- Row models are a pipeline - Flip the group-by control and watch the pipeline change shape: core -> grouped -> expanded. Group rows carry the aggregate: sum roll-up; the markup is a plain hand-styled <table>.
- Headless server-side - Paging + sorting + filtering + load on demand. The "server" owns the data and returns one page at a time; each state change fires a single request. The engine wraps only the current page.