DataGrid v2.1.0

DataGrid for Svelte 5

@sv5ui/datagrid is a separate package built on sv5ui: a virtualized, keyboard-navigable data grid assembled from feature modules you can also write yourself.

Headless core

createDataGrid returns runes and a pure row pipeline. No DOM, no styling, usable on its own.

Feature modules

Twenty-five opt-in modules built on the same hooks your own features get. Unregistered means unbundled.

Virtualized

Past a million rows, with a DOM node count that does not grow with the data.

One tab stop

ARIA grid and treegrid, roving tabindex, a live announcer, and full keyboard navigation.

Basic Example

Three props and a grid: rows, columns and a stable row id. The shorthand form registers sorting, filtering, column operations and pagination for you.

Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
$129,812.00
Quyen Tanaka
quyen.tanaka6@example.com
Growth
Analyst
$72,011.00
Mai Nguyen
mai.nguyen7@example.com
Support
Analyst
$83,226.00
Bruno Novak
bruno.novak8@example.com
Support
Analyst
$71,507.00
40 rows
1-8 of 40
<script lang="ts">
  import { DataGrid, type ColumnDef } from '@sv5ui/datagrid';

  interface Person {
    id: number;
    name: string;
    email: string;
    team: string;
    salary: number;
  }

  const people: Person[] = [/* ... */];

  const columns: ColumnDef<Person>[] = [
    { id: 'name', header: 'Name', sortable: true, filter: 'text', flex: 1 },
    { id: 'email', header: 'Email', sortable: true, filter: 'text', flex: 1.4 },
    { id: 'team', header: 'Team', sortable: true, filter: 'set', width: 120 },
    { id: 'salary', header: 'Salary', sortable: true, filter: 'number',
      align: 'right', width: 120, type: 'currency' }
  ];
</script>

<DataGrid
  data={people}
  {columns}
  getRowId={(person) => String(person.id)}
  pageSize={8}
  toolbar
/>

Two Layers

The package is a headless core and a component layer, either usable on its own.

The headless core

createDataGrid returns a GridState: Svelte 5 runes and a derived row pipeline. Use it when you want to choose the features, hold the state, or drive the grid from outside.

0 selected sort: none
Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
$129,812.00
40 rows
1-5 of 40
<script lang="ts">
  import {
    createDataGrid,
    DataGrid,
    columnOps,
    filtering,
    getSelection,
    getSorting,
    pagination,
    selection,
    sorting
  } from '@sv5ui/datagrid';

  // Only these five features are imported, so only these five are bundled.
  // Name the row type on createDataGrid and the factories follow from it.
  const grid = createDataGrid<Person>({
    data: people,
    columns,
    getRowId: (person) => String(person.id),
    features: [
      sorting(),
      filtering(),
      columnOps(),
      selection(),
      pagination({ pageSize: 5 })
    ]
  });

  // Plain derived values: no subscription, no store, no event handler
  let sort = $derived(
    getSorting(grid)
      ?.sort.map((entry) => `${entry.columnId} ${entry.direction}`)
      .join(', ') || 'none'
  );
</script>

<Badge label="{getSelection(grid)?.count ?? 0} selected" />
<Badge label="sort: {sort}" />

<Button
  label="Sort by salary"
  onclick={() => getSorting(grid)?.setSort([{ columnId: 'salary', direction: 'desc' }])}
/>
<Button label="Select every row" onclick={() => getSelection(grid)?.selectAll()} />

<DataGrid {grid} />

The component layer

DataGrid renders the whole thing. The Grid parts let you compose the chrome yourself, in any order, with your own markup between them.

Hoang Kowalski
hoang.kowalski1@example.com
Design
Manager
$127,691.00
Bruno Nguyen
bruno.nguyen2@example.com
Growth
Support
$105,146.00
Bruno Dubois
bruno.dubois3@example.com
Design
Manager
$86,538.00
Farid Haddad
farid.haddad4@example.com
Growth
Analyst
$76,443.00
Jonas Yilmaz
jonas.yilmaz5@example.com
Core
Support
$129,812.00
40 rows
1-5 of 40
<script lang="ts">
  import { Grid, createDataGrid } from '@sv5ui/datagrid';

  const grid = createDataGrid<Person>({ data, columns, getRowId, features });
</script>

<!-- The same grid, assembled by hand -->
<Grid.Root {grid} class="space-y-4">
  <Grid.Toolbar>
    <Grid.QuickFilter placeholder="Search members" />
    <Grid.FilterChips />
    <div class="ms-auto flex gap-2">
      <Grid.ExportMenu filename="members.csv" />
      <Grid.ColumnChooser />
      <Grid.DensityToggle />
    </div>
  </Grid.Toolbar>

  <Grid.Viewport>
    <Grid.Header />
    <Grid.Body />
  </Grid.Viewport>

  <Grid.StatusBar />
  <Grid.Pagination pageSizes={[5, 10, 25]} />
</Grid.Root>

The Row Pipeline

Every feature that changes which rows are shown does it by inserting a pure transform at a declared order. That is also how you read intermediate results back.

sourceNodes

40

before filtering

preWindowNodes

40

filtered and sorted

nodes

5

on screen

totalRows

40

what the footer counts

Hoang Kowalski
Design
$127,691.00
Bruno Nguyen
Growth
$105,146.00
Bruno Dubois
Design
$86,538.00
Farid Haddad
Growth
$76,443.00
Jonas Yilmaz
Core
$129,812.00
40 rows
1-5 of 40

Type above and only two numbers move: the source is untouched, and what is on screen is capped by the page size. Sorting moves none of them, because it reorders rows rather than removing them.

<script lang="ts">
  import { createDataGrid, DataGrid, filtering, getFiltering,
           pagination, sorting } from '@sv5ui/datagrid';

  // Every stage is a pure transform of RowNode[], inserted at a declared
  // order, so a stage never has to know what else is registered:
  //
  //   data -> filter (100) -> sort (200) -> group (300) -> flatten (400)
  //        -> pin-split (500) -> window (900) -> rendered rows
  //
  // Each link is memoized with $derived, so a stage whose input did not
  // change is not recomputed.
  const grid = createDataGrid<Person>({
    data: people,
    columns,
    getRowId,
    features: [sorting(), filtering(), pagination({ pageSize: 5 })]
  });

  // Page size belongs to the footer the grid already renders; only the
  // filter needs a control of its own, since this grid carries no toolbar.
  let query = $state('');

  // Every grid setter runs inside the library's mutator helper, so the state
  // it reads on its way out does not become a dependency of the caller.
  // Calling one straight from an effect is safe.
  $effect(() => {
    getFiltering(grid)?.setQuickFilter(query);
  });

  // The output is readable at any point along the chain, and each one is a
  // rune, so a derived value tracks it without a subscription.
  let stages = $derived([
    { label: 'sourceNodes', value: grid.sourceNodes.length, note: 'before filtering' },
    { label: 'preWindowNodes', value: grid.preWindowNodes.length, note: 'filtered and sorted' },
    { label: 'nodes', value: grid.nodes.length, note: 'on screen' },
    { label: 'totalRows', value: grid.totalRows, note: 'what the footer counts' }
  ]);
</script>

<Input bind:value={query} placeholder="Filter these rows" />

{#each stages as stage (stage.label)}
  <div class="rounded-lg border border-outline-variant p-3">
    <code>{stage.label}</code>
    <p class="text-xl font-semibold">{stage.value}</p>
    <p class="text-xs">{stage.note}</p>
  </div>
{/each}

<DataGrid {grid} />

Feature Modules

Opt-in and tree-shakeable. A feature you do not register is never imported, so its code stays out of your bundle.

FeatureAdds
sorting()Multi-sort with priority badges, per-type comparators, null ordering, sortFn and sortField
filtering()Quick filter plus text, number, date, set and boolean column filters, two conditions per column, a filter row under the header, chips
columnOps()Resize, reorder, fold header groups, pin left and right, hide, autosize, column menu and chooser
selection()Single or multi selection, checkbox column, select-all, Shift range, TSV copy, CSV export
editing()Cell and row editing with ten editors, schema validation, transactions, undo and redo, paste
pagination()Client paging plus the hooks a server row model needs
virtualization()Row and column virtualization, fixed or measured row heights
rowPinning()Rows pinned to the top or the bottom, outside filter and sort
rowReorder()Pointer and keyboard row reorder with an auto-scrolling drag preview

Performance

Measured on Chromium at a 1500x950 viewport with 39 columns of mixed renderers. The DOM node count is the number worth reading: it is the same at a million rows as at a hundred thousand, because only the visible window is rendered.

Sorting and filtering are benched apart from these, being arithmetic rather than rendering: at 100k rows across four columns a numeric sort is 18ms, a string sort 265ms, and a quick filter keystroke 6ms. The performance page carries both tables and what they leave out.

Metric100k rows500k rows1M rows
Data into the grid219ms251ms416ms
JS heap100MB315MB472MB
DOM nodes779779779
Scroll, median frame19ms23ms35ms

DataGrid or Table?

Both ship in the sv5ui ecosystem. The Table component is markup; the DataGrid is a data pipeline with a viewport.

What you needUse
A handful of static rows with custom markupTable
Sorting, filtering and paging over a few hundred rowsDataGrid
Thousands of rows in a scroll viewportDataGrid
Inline editing with validationDataGrid
Server-driven filter, sort and pagingDataGrid

Upgrading from 2.0

Everything in 2.1 is additive at run time: register pivot() or flashCells() and they work, leave them out and nothing changes. Three things touch code that already exists.

ChangeWhat to do
DataGridLabelsGained 41 required members, for the wording a pivot brings. Passing the grid a subset is unaffected and so is a locale pack of your own; a complete table passed as the base of mergeLabels no longer type-checks, and should be merged onto the built-in table instead
sv5ui2.7.0 is the floor, up from 2.5.0. The header resizes columns through a hook 2.7.0 added, so upgrade sv5ui before the grid
menuItemsA feature of your own is now asked by every column menu, with columnId, as well as by the row context menu, with node. Return an empty array for the one you are not for, or your items appear in both

Upgrading from 1.x

Almost everything in 2.0 is additive: register a feature you did not have before and it works. Four things are not, and these are all of them.

Each one is a behaviour a 1.x grid relied on. The code below is the change in full.

ChangeWhat to do
DataGridLabelsGained 147 required members, because the new features brought their own wording and the label table is one table. Passing the grid a subset is unaffected; a complete table of your own passed as the base of mergeLabels now merges the other way round
neq on a numberKeeps a blank cell, the way notEqual on a text column always has. Pair it with notBlank if you were relying on it to drop blanks, and change the server you wrote against the contract the same way
A typed columnDraws text it cannot parse rather than drawing nothing. A blank cell is unchanged and still draws emptyText. This reaches masking too: a policy() rule substituting a string into a typed column now shows the mark
The loading skeletonIts cells carry role="gridcell", because a row owning none is a row a screen reader cannot read. A test asserting no gridcell exists during a fetch should assert no data is on screen instead
// 1. A complete DataGridLabels of your own is 147 members short now.
//    Merge onto the built-in table instead of under it.
mergeLabels(overrides, myCompleteTable);        // no longer type-checks
mergeLabels({ ...myCompleteTable, ...overrides });

// 2. neq on a number keeps a blank cell. Say so if you were excluding them.
setColumnFilter('score', { kind: 'number', op: 'neq', value: 5 });
setColumnFilter('score', {
  kind: 'group',
  join: 'and',
  conditions: [
    { kind: 'number', op: 'neq', value: 5 },
    { kind: 'number', op: 'notBlank' }
  ]
});

// 3. A typed column draws text it cannot parse, so the cell's text changed.
//    { price: 'notanumber' } in a type: 'number' column
cell.textContent;   // was '', is now 'notanumber'
//    A blank cell is unchanged: null, undefined and '' still draw emptyText.

// 4. The loading skeleton draws cells now, so a grid mid-fetch has gridcells.
//    Ask whether any data is on screen rather than whether any cell is:
const cells = [...container.querySelectorAll('[role="gridcell"]')];
const empty = cells.every((cell) => cell.textContent?.trim() === '');

// Not breaking, but worth knowing: DateFilterOp gained notEqual, so a switch
// of your own over that union is no longer exhaustive, and the footer's page
// range separates its numbers with a hyphen rather than an en dash.

API Stability

Everything exported from the package root is public and covered by semver. This is version 2.1.0, so the next breaking change to any of it waits for 3.0. Internal helpers such as pipeline transforms, filter compilation, undo plumbing and column sizing maths stay unexported and change freely between releases.

SurfacePromise
Exports from the package rootPublic, and covered by semver
Three data attributes: data-dg-cell, data-dg-row-id and data-dg-header-cellPublic. Delegate from a wrapper and they will be there
The ui slot names and the density CSS variablesPublic. A slot may be added; one in use will not vanish silently
Class names, element nesting, the body transformInternal. Free to change between releases
Pipeline transforms, filter compilation, undo plumbing, sizing mathsInternal, and unexported. Needing one is a gap in the extension points