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

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>

View this example on GitHub

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.