100k rows × 100 columns
Row + column virtualization. Chunked load with progress + cancellation.
A live, editable Svelte 5 data grid example from the SvGrid gallery (Getting Started). See the SvGrid documentation for the full API.
About this example
Row and column virtualization in the Svelte 5 data grid: 100,000 rows by 100 columns of sales-rep ledgers with monthly revenue, units and margin. Data is generated in chunks with a progress indicator and a cancel button, then the grid renders only the rows and columns in view, so both axes scroll smoothly. Sorting, filtering, cell selection and inline editing stay on at that size.
Row + column virtualization make a wide grid scroll smoothly.
The data is a sales team where each rep carries a rolling monthly ledger - Revenue / Units / Margin per month going back a couple of years. That's a genuinely wide real-world shape (not placeholder "Metric N" columns), so scrolling both axes exercises the virtualizer on data that means something.
The user can scale it up at runtime. The default is 10,000 rows × 55 columns - a realistic enterprise size that mounts in well under a second. The 100,000-row option pushes the grid hard; expect a brief pause on mount, then smooth scrolling once the virtualizer is live.
Imports, features and API used
Imports: @svgrid/grid, ../shared/seed
Table features registered: rowSortingFeature, columnFilteringFeature
Frequently asked questions
How do I enable column virtualization in SvGrid?
Set virtualization for rows and columnVirtualization for columns. overscan and columnOverscan control how many extra rows and columns render outside the viewport; columnWidth gives wide grids a default width so the virtualizer can lay columns out without measuring each one.
How big can the dataset go?
The demo defaults to 10,000 rows by 55 columns and lets you scale to 100,000 rows at runtime. The 100k option pauses briefly while the data is generated, then scrolls smoothly because the DOM only ever holds the visible slice.
Why is the data loaded in chunks?
Generating 100,000 rows in one synchronous pass would freeze the tab. The demo builds the array in chunks with a progress bar and a cancel button, which is the same pattern to use when streaming rows from a server.
Related documentation
Related articles
- Performance Tips for SvGrid with Svelte 5 Runes - Stable references, batched transactions, and $state.raw - the three techniques that keep a 30,000-row live grid at 60 fps without heroics.
Source code (06-large-dataset.svelte)
<script lang="ts">
/**
* 06. Large dataset, virtualized
* ------------------------------
* Row + column virtualization make a wide grid scroll smoothly.
*
* The data is a sales team where each rep carries a rolling monthly ledger -
* Revenue / Units / Margin per month going back a couple of years. That's a
* genuinely wide real-world shape (not placeholder "Metric N" columns), so
* scrolling both axes exercises the virtualizer on data that means something.
*
* The user can scale it up at runtime. The default is 10,000 rows × 55
* columns - a realistic enterprise size that mounts in well under a second.
* The 100,000-row option pushes the grid hard; expect a brief pause on
* mount, then smooth scrolling once the virtualizer is live.
*/
import {
SvGrid,
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
type ColumnDef,
type GridColumns,
} from '@svgrid/grid'
import { makeWidePeople, metricKind, type WidePerson } from '../shared/seed'
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
})
// `cols` is the number of monthly-KPI columns; the label counts the 5 fixed
// identity columns too. The smaller tiers keep cols a multiple of 3 so months
// group cleanly; the top tier fills to a round 100 columns (the last month is
// partial, which the KPI-cycling handles fine).
type Size = { rows: number; cols: number; label: string }
const sizes: Size[] = [
{ rows: 1_000, cols: 24, label: '1k × 29' },
{ rows: 10_000, cols: 48, label: '10k × 53' },
{ rows: 50_000, cols: 72, label: '50k × 77' },
{ rows: 100_000, cols: 95, label: '100k × 100' },
]
let size = $state<Size>(sizes[1]!)
let busy = $state(false)
let rows = $state.raw<WidePerson[]>([])
let columns = $state.raw<ColumnDef<typeof features, WidePerson>[]>([])
let mountedAt = $state(0)
// Month labels for the rolling ledger, newest first: "Aug '26", "Jul '26"…
// One label per calendar month back from today.
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function monthLabel(back: number): string {
const now = new Date()
const d = new Date(now.getFullYear(), now.getMonth() - back, 1)
return `${MONTHS[d.getMonth()]} '${String(d.getFullYear()).slice(-2)}`
}
// Each wide column is one month of one KPI, cycling Revenue / Units / Margin.
// The header and number format follow the KPI, so the grid reads like a real
// performance ledger rather than "Metric 0 … Metric 94".
const KPI = [
{ name: 'Revenue', format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ name: 'Units', format: { type: 'number', options: { maximumFractionDigits: 0 } } },
{ name: 'Margin', format: { type: 'percent', valueIsPercentPoints: true, options: { maximumFractionDigits: 1 } } },
] as const
function buildColumns(metrics: number): GridColumns<WidePerson> {
const base: GridColumns<WidePerson> = [
{ field: 'firstName', header: 'First name', editorType: 'text', width: 140 },
{ field: 'lastName', header: 'Last name', editorType: 'text', width: 140 },
{ field: 'department', header: 'Team', editorType: 'text', width: 130 },
{ field: 'country', header: 'Region', editorType: 'text', width: 100 },
{ field: 'status', header: 'Status', editorType: 'text', width: 110 },
]
const ledger: GridColumns<WidePerson> = []
for (let i = 0; i < metrics; i++) {
const kpi = KPI[metricKind(i)]!
// Three KPIs share each month, so month N advances every 3 columns.
const month = monthLabel(Math.floor(i / 3))
ledger.push({
field: `metric_${i}` as `metric_${number}`,
header: `${month} · ${kpi.name}`,
editorType: 'number',
format: kpi.format,
width: 150,
})
}
return [...base, ...ledger]
}
async function load(next: Size) {
busy = true
// Unmount the grid first so the heavy old rows are GC'd before the new ones
// are generated. Without this, peak memory is ~2× the larger size.
rows = []
columns = []
await new Promise((r) => requestAnimationFrame(r))
const t0 = performance.now()
const generated = makeWidePeople(next.rows, next.cols, 1337)
columns = buildColumns(next.cols)
rows = generated
size = next
mountedAt = Math.round(performance.now() - t0)
busy = false
}
// Initial load
$effect(() => {
if (rows.length === 0 && !busy) load(size)
})
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
<span class="font-medium">Dataset:</span>
{#each sizes as option (option.label)}
{@const active = option.rows === size.rows && option.cols === size.cols}
<button
type="button"
onclick={() => load(option)}
disabled={busy || active}
class="rounded border px-3 py-1 ds-btn {active ? 'ds-btn-on font-semibold' : ''} disabled:opacity-50"
>{option.label}</button>
{/each}
<span class="ml-auto ds-meta">
{#if busy}
Generating…
{:else if rows.length}
{size.rows.toLocaleString()} rows · {size.cols + 5} columns · generated in {mountedAt} ms
{/if}
</span>
</div>
{#if rows.length}
<div class="flex-1 min-h-0">
<SvGrid responsive={true}
columnResize
data={rows}
columns={columns}
features={features}
filterMode="menu"
selectionMode="cell"
enableInlineEditing={false}
enableCellSelection={true}
showRowNumbers={true}
virtualization={true}
columnVirtualization={true}
rowHeight={32}
overscan={8}
columnOverscan={3}
columnWidth={150}
containerHeight="100%"
/>
</div>
{/if}
</section>
<style>
/* Toolbar chrome follows the active grid theme via --sg-* tokens. */
.ds-btn {
border-color: var(--sg-border, #cbd5e1);
background: var(--sg-bg, transparent);
color: var(--sg-fg, inherit);
}
.ds-btn:not(.ds-btn-on):hover:not(:disabled) { background: var(--sg-row-hover-bg, transparent); }
.ds-btn-on { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); }
.ds-meta { color: var(--sg-muted, #64748b); }
</style>More Getting Started examples
- Trading desk - live - 10,000 securities ticking on a 500 ms feed. Pinned Symbol + P&L, per-company logo marks, direction-coloured sparklines, sector chips, a KPI strip, and a notifications bell that flags standout movers. The hero.
- Quick start - A realistic 25-row × 9-column grid with sort, filter, selection, inline editing, and column resize all enabled.
- Shortcut config - Capabilities are off by default - opt into sort / filter / edit / group / paging with one boolean shortcut each. No `features` array, no fine-grained props. Toggle the switches to build the config live.
- Admin template - Self-contained admin app: sidebar + three pages (Dashboard, Orders w/ Enterprise export bar, Customers w/ inline edit). Read end-to-end in one file.
- 1 million rows - A literal 1,000,000-row dataset with sort, filter, group, scroll, and inline edit all on. Chunked generation with progress.