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.
bun add @sv5ui/datagrid and ships on its own schedule. This page is v2.1.0 of the grid, which has nothing
to do with v2.9.0 of sv5ui. Its releases have a changelog of their own. 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.
<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.
<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.
<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
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.
| Feature | Adds |
|---|---|
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.
| Metric | 100k rows | 500k rows | 1M rows |
|---|---|---|---|
| Data into the grid | 219ms | 251ms | 416ms |
| JS heap | 100MB | 315MB | 472MB |
| DOM nodes | 779 | 779 | 779 |
| Scroll, median frame | 19ms | 23ms | 35ms |
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 need | Use |
|---|---|
A handful of static rows with custom markup | Table |
Sorting, filtering and paging over a few hundred rows | DataGrid |
Thousands of rows in a scroll viewport | DataGrid |
Inline editing with validation | DataGrid |
Server-driven filter, sort and paging | DataGrid |
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.
| Change | What to do |
|---|---|
DataGridLabels | Gained 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 |
sv5ui | 2.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 |
menuItems | A 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.
| Change | What to do |
|---|---|
DataGridLabels | Gained 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 number | Keeps 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 column | Draws 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 skeleton | Its 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.locales and locale go on createDataGrid, so two grids on one page may speak different
languages and nothing global has to be put back afterwards. See Localization. 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.
| Surface | Promise |
|---|---|
| Exports from the package root | Public, and covered by semver |
| Three data attributes: data-dg-cell, data-dg-row-id and data-dg-header-cell | Public. Delegate from a wrapper and they will be there |
| The ui slot names and the density CSS variables | Public. A slot may be added; one in use will not vanish silently |
| Class names, element nesting, the body transform | Internal. Free to change between releases |
| Pipeline transforms, filter compilation, undo plumbing, sizing maths | Internal, and unexported. Needing one is a gap in the extension points |