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.
cellDecoration can carry inline style, and cellValue gates a cell's value on every way out of the grid. See them on Filtering, Header Groups and Custom Features, or all at once on the Real World example.bun add @sv5ui/datagrid and ships on its own schedule. This page is v1.3.0 of the grid, which has nothing
to do with v2.6.1 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
Nine 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 |
API Stability
Everything exported from the package root is public and covered by semver. This is version 1.3.0, so a breaking change to any of it waits for 2.0. Internal helpers such as pipeline transforms, filter compilation, undo plumbing and column sizing maths stay unexported and change freely between releases.
Row grouping, tree data, master and detail rows, range selection and infinite scroll are not in this package. They are planned for a separate pro package.
| Surface | Promise |
|---|---|
| Exports from the package root | Public, and covered by semver |
| The two data attributes, data-dg-cell and data-dg-row-id | 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 |