A high-performance data grid for Svelte 5.
Virtualized past a million rows, keyboard-navigable, and assembled from
feature modules you can write yourself.
Quick start · Changelog · Issues
Built on sv5ui. Register only the features you use, and nothing else reaches your bundle.
<script lang="ts">
import { DataGrid, type ColumnDef } from '@sv5ui/datagrid'
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', sortable: true, filter: 'text', flex: 1 },
{ id: 'age', header: 'Age', sortable: true, align: 'right', width: 100 }
]
</script>
<DataGrid data={people} {columns} getRowId={(p) => String(p.id)} toolbar />- Features
- Installation
- Quick start
- Architecture
- Feature modules
- Extension points
- Columns
- Localization
- State persistence
- Server row model
- Theming
- Accessibility
- Performance
- DOM contract
- API stability
- Contributing
| Area | What you get |
|---|---|
| Rows | Row and column virtualization past a million rows, fixed or per-row heights, 'auto' measured rows, pinned rows, full-width rows |
| Columns | Resize, reorder, pin left/right, hide, nested header groups that fold, autosize, colSpan and rowSpan |
| Sorting | Multi-sort with priority badges, per-type comparators, null ordering, sortFn, sortField |
| Filtering | Quick filter plus text, number, date, set and boolean column filters, two conditions per column, a filter row under the header, chips |
| Selection | Single or multi, checkbox column, select-all, Shift-range, TSV copy, CSV export |
| Editing | Cell and row editing with ten sv5ui editors, schema validation, transactions, undo/redo, clipboard paste |
| Reordering | Pointer and keyboard row reorder with an auto-scrolling drag preview |
| Persistence | Versioned JSON snapshots, localStorage auto-sync, migrate hook |
| Localization | Twelve languages, chosen from the page's own; number and date formatting follow |
| Accessibility | ARIA grid and treegrid, one tab stop, full keyboard navigation, axe-clean |
| Server | rowModel: 'server' with normalized filter and sort requests, paged or infinite blocks |
| Grouping | Group by any number of columns, thirteen aggregators, group footers, a grand total, and values read as a share of one of them |
| Structure | Tree data from getChildren or getParentId, and a detail panel under any row |
| Formulas | Calculated columns from an expression, 31 functions, a dependency graph that catches a cycle, and no eval |
| Ranges | Excel-style cell ranges, multiple selections, a fill handle that reads a series, cut and move, and a summary bar |
| Finding | Find and replace across the rows a filter left, whole-cell and case options |
| Conditions | Colour scales, data bars, duplicates, top N and expression rules, exported into the workbook as real Excel rules |
| Filter builder | Nested (A AND B) OR C, operators per column type, alongside the column filters rather than instead of them |
| Import | CSV, TSV, XLSX and clipboard, type guessing, column mapping, validation, and rows staged in the grid to fix in place |
| Export | CSV, and XLSX written without a dependency, on its own entry at @sv5ui/datagrid/xlsx |
| Views | Named views in localStorage or storage of your own, and a link that carries the whole state |
| Policy | Column, row and cell masking applied at render, export, clipboard, search, facet and edit |
| Off the main thread | A worker row model that filters and sorts a columnar copy, with a main-thread fallback |
| Command palette | Ctrl/Cmd+K over the commands the registered features offer |
Features are opt-in. A feature you do not register is never imported, so its
code stays out of your bundle, and src/tests/bundle-shape.test.ts builds a
real entry to prove it.
pnpm add @sv5ui/datagrid sv5uiImport both themes in your Tailwind entry stylesheet:
@import 'sv5ui/theme.css';
@import '@sv5ui/datagrid/theme.css';Tailwind 4 skips node_modules when it scans for class names, so a package has
to register its own compiled output. Each theme file does that for itself, which
is why no @source path of your own is needed.
| Package | Version |
|---|---|
| SvelteKit | 2.x |
| Svelte | 5.x |
| Tailwind CSS | 4.x |
| sv5ui | 2.5.0 or later |
SvelteKit is required rather than optional: sv5ui resolves $app/state, which
only a SvelteKit app provides. @iconify/svelte and tailwindcss are declared
as peer dependencies so that the grid and sv5ui share a single instance of
each; package managers that install peers automatically (pnpm 8+, npm 7+)
resolve them for you.
Every icon the grid draws is bundled and registered into Iconify's store, so neither the grid nor the network is involved at render time. Nothing to configure: importing the grid registers them, before anything on the page draws. If your own UI happens to use one of the same icons, it resolves locally too.
registerDataGridIcons is exported for the one case the import does not
cover - a grid behind a dynamic import(), where your own icons may render
before the grid's module is even fetched:
<!-- src/routes/+layout.svelte -->
<script>
import { registerDataGridIcons } from '@sv5ui/datagrid'
registerDataGridIcons()
</script>It is idempotent.
The set covers what the grid itself draws. Icons you hand it - RowAction.icon,
a menuItems entry, typeOptions.trueIcon, anything inside a cell snippet -
are yours to bundle, as is any icon of your own the grid never uses:
import { addCollection } from '@iconify/svelte'
addCollection({ prefix: 'lucide', icons: { rocket: { body: '<path .../>' } } })datagridIcons is exported too, if you want to read the shape or merge it.
<script lang="ts">
import { DataGrid, type ColumnDef } from '@sv5ui/datagrid'
interface Person {
id: number
name: string
age: number
}
const people: Person[] = [/* ... */]
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', sortable: true, filter: 'text', flex: 1 },
{ id: 'age', header: 'Age', sortable: true, align: 'right', width: 100 }
]
</script>
<DataGrid data={people} {columns} getRowId={(p) => String(p.id)} pageSize={10} toolbar />That gives you sorting, filtering, column operations and pagination.
Use createDataGrid when you want to choose the features, hold the state, or
drive the grid from outside:
<script lang="ts">
import {
createDataGrid,
DataGrid,
columnOps,
filtering,
selection,
sorting,
virtualization
} from '@sv5ui/datagrid'
const grid = createDataGrid({
data: people,
columns,
getRowId: (p) => String(p.id),
features: [sorting(), filtering(), columnOps(), selection(), virtualization()]
})
</script>
<DataGrid {grid} toolbar class="h-[640px]" />The package is two layers, either usable on its own.
Headless core. createDataGrid returns a GridState: Svelte 5 runes and a
derived row pipeline of filter, sort and window. No DOM, no styling.
Components. DataGrid renders the whole thing. The Grid.* parts
(Root, Viewport, Header, Body, Toolbar, Pagination, StatusBar and
others) let you compose the chrome yourself.
The pipeline is a chain of pure transforms over a RowNode[]. Features insert
stages at a declared order, so a stage never has to know what else is
registered.
| Feature | Adds |
|---|---|
sorting() |
Multi-sort, header cycle, priority badges |
filtering() |
Quick filter, column filters, filter row, chips |
columnOps() |
Resize, reorder, pin, hide, autosize |
selection() |
Checkbox column, copy, CSV export |
editing() |
Cell/row editing, validation, undo/redo, paste |
pagination() |
Client paging and the server hooks |
virtualization() |
Row and column virtualization |
rowPinning() |
Rows pinned to the top or bottom |
rowReorder() |
Drag grip and keyboard reorder |
| Feature | Adds |
|---|---|
grouping() |
Group by N columns, thirteen aggregators, footers, grand total |
tree() |
Nested or flat parent/child data |
masterDetail() |
A detail panel under a row |
showValuesAs() |
A number read as a share of a total, a parent or a row |
formula() |
Calculated columns from an expression |
advancedFilter() |
A nested condition tree beside the column filters |
rangeSelection() |
Cell ranges, fill handle, cut and move, clipboard |
findReplace() |
Find and replace across the rows a filter left |
conditionalFormatting() |
Colour scales, data bars, duplicates, top N, expressions |
dataImport() |
CSV, TSV, XLSX and clipboard, staged in the grid |
savedViews() |
Named views and a link that carries the whole state |
policy() |
Masking at render, export, clipboard, search, facet and edit |
serverRowModel() |
Paged or infinite blocks fetched from a source |
workerDataSource() |
The same, filtered and sorted off the main thread |
commandPalette() |
Ctrl/Cmd+K over what the registered features offer |
The workbook writer is not a feature: buildGridXlsx and createWorkbook are
functions, on their own entry at @sv5ui/datagrid/xlsx, because writing a
spreadsheet involves no grid.
Call a factory inside the features array, as above, and TRow is inferred
from the array's own type. A factory held in a variable first has nothing to
infer from and resolves to GridFeature<unknown>, so spell the argument out
there: const sort = sorting<Person>().
filtering({ floatingRow: true }) draws a row under the header, one field per
column, and <DataGrid floatingFilters /> is the same thing for a grid it
builds itself. The field filters in the operator the column already uses, so
changing the operator in the panel and then typing in the row keeps it.
Each kind of filter gets the control it needs: a field for text, a number field, a date picker, a choice for a boolean, and a searchable list of ticks for a set, which reads its values from the column the first time it is opened.
The row holds one condition, however many values are ticked in it. What says
more than that stays with the panel and reads back in the row as a summary and
a button that opens it: two conditions joined, a between range, and blank
or notBlank, which an empty field would report as no filter at all.
It is a row of the grid, not a strip above it. Arrow down from the header lands in the field, arrow down again is the first body row, and the rows below are numbered under it for a screen reader.
Read a feature's state back with the matching accessor:
import { getSelection, getSorting } from '@sv5ui/datagrid'
getSelection(grid)?.selectedIds
getSorting(grid)?.setSort([{ columnId: 'name', direction: 'asc' }])The accessor is the typed path: it narrows to the feature's own class, generic
in TRow, with nothing optional about what it returns. grid.api is the flat
alternative - every feature's methods in one bag, each one optional, because
the grid that has setPage is the one that registered pagination():
grid.api.setPage?.(2) // present only with pagination()
grid.api.getState() // the kernel's own, always thereA feature declares its methods by augmenting GridApi from
@sv5ui/datagrid, which is what the built-in features do:
declare module '@sv5ui/datagrid' {
interface GridApi {
highlightNegative?: (columnId: string) => void
}
}Registering a feature is enough to use it. These are the named exports your
own code reaches for when it has to meet a feature halfway: read back a row
the grid drew, catch what a feature throws, reuse a calculation it made, or,
from inside a feature's own component, reach the grid it is mounted in.
| Export | Comes with | Reach for it when |
|---|---|---|
isDataRow(node) |
the kernel | You walk grid.preWindowNodes and want only rows carrying data, skipping a group header, a footer, a detail panel and a loading row |
isLoadingRow(row) |
serverRowModel() |
A cell snippet draws a row still on its way differently from one that arrived |
isDetailNode(id) |
masterDetail() |
You hold a row id and need to know whether it names a detail panel rather than a row |
totalsKindOf(id) |
grouping() |
The same question for a group footer ('footer') or the grand total ('grandTotal'), and null for anything else |
aggregate(aggregation, values, rows) |
grouping() |
You want the number a group footer would show, somewhere other than the grid: a summary card, a chart, a second table |
autoColumns(rows, options?) |
the kernel | The shape of the data is known only at runtime, so the columns are read off a sample of the rows |
localStorageViews |
savedViews() |
You take the default store, or write an object of the same shape to keep views on a server instead |
ShareTooLongError |
savedViews() |
Packing a whole grid state into a link can pass what a URL carries |
FormulaError, isFormulaError |
formula() |
A calculated cell holds one of these instead of a number when the expression cannot answer |
FUNCTION_NAMES |
formula() |
You build an editor for expressions and want completion over the functions there are |
getGridContext() |
the kernel | A feature's component needs the grid it is mounted in; it is handed no props |
getGridElement() |
the kernel | The same component needs the root element, for a listener, a measurement or a layer over the rows |
The grid offers three lists of nodes, and which one you want depends on the
question. grid.nodes is what is on screen after paging and virtualization.
grid.preWindowNodes is everything the grid would draw, so a row inside a
collapsed group or tree node is not in it. grid.filteredNodes is the list one
stage earlier, before anything folded rows away.
Two counts sit on top of those, and they answer different questions.
grid.totalRows is the length of the drawn list: what keyboard navigation
bounds itself by, what aria-rowcount reports, and it counts a group header as
a row because that is a row you can focus. grid.filteredRowCount is how many
data rows a filter left, which is what the status bar shows and what a screen
reader is told, and it does not move when a group is opened or shut. A feature
holding rows the pipeline cannot see, as a nested tree does, answers for itself
through GridFeature.rowCount.
A calculated column never throws at you. It puts a FormulaError in the cell,
carrying a code that reads the way a spreadsheet's does and a detail that
says which name or which value caused it:
import { isFormulaError } from '@sv5ui/datagrid'
const value = grid.getValue(node, column)
if (isFormulaError(value)) {
console.warn(value.code, value.detail) // '#CYCLE', '"total" refers back to itself'
}FormulaErrorCode is the union of what code can be: '#VALUE', '#DIV/0',
'#NAME', '#NUM', '#LIMIT' and '#CYCLE'. The cell prints the code, so a
column in a cycle reads #CYCLE down its length and the columns outside that
cycle keep working.
Those two answer a cell. To check an expression before it becomes one, which is
what an editor offering FUNCTION_NAMES needs next, write it and ask:
const formulas = getFormula(grid)!
formulas.set('draft', expression)
const failure = formulas.errorOf('draft') // null when it parsesThe parser itself is not exported. It answers an internal tree that would have
to stay still for as long as the package does, and errorOf carries the two
things an editor actually shows: the message, and where in the string it went
wrong.
Sharing a state as a link is the one saved-views call that can refuse. A grid
holding many columns, a long filter tree and a set filter with hundreds of
ticks encodes past what a URL carries, so shareLink and shareToken reject
rather than hand back a link that will not open:
import { getSavedViews, ShareTooLongError } from '@sv5ui/datagrid'
try {
const link = await getSavedViews(grid)!.shareLink()
if (link) location.href = link
} catch (error) {
if (error instanceof ShareTooLongError) {
alert(`${error.length} characters is too long for a link. Save it as a view.`)
} else throw error
}A feature is a plain object. The built-in features use nothing that is not available to yours.
| Hook | What it does |
|---|---|
pipelineStage |
an ordered, pure transform of the row list (filter, sort, group) |
createState |
reactive state exposed on grid.state[id] |
createApi |
imperative methods merged into grid.api (see below) |
keybindings |
keyboard bindings, with a when guard |
menuItems |
column and context menu entries |
cellDecoration |
per-cell classes, inline style and aria-selected |
cellValue |
stands between a cell's value and every way it leaves the grid |
component |
a component the grid mounts in its root, for effects and the DOM |
serialize |
the feature's slice of a state snapshot |
hydrate |
restores what serialize produced |
const highlightNegative = (): GridFeature<Row> => ({
id: 'highlight-negative',
cellDecoration: ({ node, column }) =>
column.id === 'balance' && node.row.balance < 0 ? { class: 'text-error' } : undefined
})cellDecoration runs for every rendered cell, so keep it cheap. A grid whose
features do not define it skips the work entirely.
A class cannot name a value computed per cell, so the hook also takes style,
a record keyed by CSS property - custom properties included, which is how a
feature reaches a pseudo-element:
const heat = (max: number): GridFeature<Row> => ({
id: 'heat',
cellDecoration: ({ node, column }) =>
column.id === 'total'
? {
style: {
'background-color': `color-mix(in oklab, var(--color-primary) ${Math.round((node.row.total / max) * 40)}%, transparent)`
}
}
: undefined
})Several features decorating the same cell merge per property, the later one winning. The grid writes its own layout as style directives, which outrank the attribute a decoration lands in, so a decoration can paint a cell but cannot move it out of its column or unpin it.
A class can only paint a cell. cellValue decides what the value is on the
way out, and it covers every way out at once: the cell and its tooltip, CSV,
the clipboard, the text a quick filter searches, the list a set filter offers,
and the draft an editor opens with.
const maskSalary = (visible: () => boolean): GridFeature<Row> => ({
id: 'mask-salary',
// Asked per column, not per value: return one reader and it is reused for
// the whole pass over the rows.
cellValue: ({ column }) => (column.id === 'salary' && !visible() ? () => '***' : undefined)
})Answer in the type the column draws. A built-in renderer formats what it is
given, so '***' on a type: 'currency' column parses as no number and the
cell draws empty; null draws the column's empty text, and a mark of your own
needs an untyped column or a cell snippet. Nothing leaks either way.
Hand the value back unchanged - the same reference - for a cell you are leaving alone; the grid compares by identity. A cell whose value a reader substitutes is one the grid refuses to edit, since an editor opened on it would commit the substitute over the real data.
Two things it deliberately does not cover. Sorting reads a column n log n
times and stays on the raw value, so a masked column can still be ordered by
what it hides; a filter predicate decides which rows survive and stays raw for
the same reason, so a narrowing filter plus a row count says something about
what was hidden. Take sortable and filter off a column you mask. And the
row object itself still reaches your own cell snippet, cellClass and
tooltip - this is a gate on the grid's own output, not a security boundary:
data that must not reach the browser should not be sent to it.
Set type and the matching sv5ui component renders the cell, with no snippet
of your own:
const columns: ColumnDef<Member>[] = [
{ id: 'name', type: 'user', typeOptions: { avatar: (m) => m.avatar } },
{ id: 'team', type: 'badge', typeOptions: { colors: { Core: 'primary' } } },
{ id: 'salary', type: 'currency', typeOptions: { currency: 'EUR' } },
{
id: 'actions',
type: 'actions',
typeOptions: { actions: (m) => [{ label: 'Edit', onSelect: edit }] }
}
]Available types: text, number, currency, percent, date, datetime,
boolean, badge, user, progress, rating, link, actions.
Number and date types go through Intl, with formatters cached per
configuration because a renderer runs on every visible cell. percent expects
a 0 to 1 ratio unless you set wholePercent. Either way its filter panel and
chips speak percentages, so a cell reading 5% is found by typing 5; what the
filter stores is what the row holds, 0.05 for a ratio column and 5 for a
whole one, so a persisted filter and a server request keep the row's own
units. A cell snippet always wins over
type, so a column can graduate to a custom renderer without changing anything
else.
Declaring both is the way to decorate without losing the formatting. The
snippet is handed formatted, the text the built-in renderer would have
printed, so it never restates the column's own typeOptions:
{
id: 'budget',
type: 'currency',
typeOptions: { currency: 'USD' },
cell: budgetCell
}
{#snippet budgetCell({ value, formatted }: DataGridCellContext<Row>)}
{formatted}
{#if Number(value) > 300_000}
<Badge label="high" color="warning" size="xs" />
{/if}
{/snippet}formatted is undefined where the built-in rendering is a widget rather than
text - boolean, badge, user, progress, rating, link, actions -
because there is no string standing for one. It is computed only if the snippet
reads it. The snippet also receives column, so a renderer can reach its own
def, alignment or id; cellClass, tooltip, colSpan and rowSpan receive
it too.
tooltip: true shows the text the cell is showing, through sv5ui's Tooltip -
the design system's, not the browser's title. A function takes its place when
the text should say more; it receives the cell context, formatted included:
{ id: 'score', type: 'number', tooltip: ({ row, value }) => `${row.name}: ${value}/100` }The trigger wraps the cell, so hovering anywhere in it opens the tooltip, and it is taken out of the tab order: the grid is one tab stop, and a page of rows must not become a page of them. A blank cell gets no tooltip at all.
Text the column never asked to explain is a separate matter: a cell whose
content is clipped gets a plain title on hover, measured only when hovered,
because wrapping every cell in the grid is not affordable. tooltip: false
turns that off for a column that manages its own.
Null, undefined and empty string all render as an em dash, whatever the
column's type and whether it declares one at all. typeOptions.emptyText
overrides the text per column - not to be confused with the emptyText prop on
<DataGrid>, which is the message for a grid with no rows at all. A cell
snippet owns its own output, formatted included: blanks arrive there already
turned into that text.
colSpan(ctx) and rowSpan(ctx) return how many cells to merge from the
current one. Covered cells are not rendered, the merged cell carries
aria-colspan and aria-rowspan, and it is the single tab stop for the block.
{ id: 'region', rowSpan: (ctx) => runLengthAt(ctx.rowIndex) }Row spans are resolved against the whole row list rather than the rendered
window, so scrolling into the middle of one still draws it. They are sized from
the rows they cover, so use them with uniform row heights rather than 'auto'.
A group folds when one of its children says what it is for. columnGroupShow: 'open' marks the detail a closed group puts away, 'closed' the summary it
folds down to, and a child that says neither is drawn either way:
const columns: ColumnDef<Row>[] = [
{
id: 'pay',
header: 'Pay',
collapsed: true, // its starting state, if not open
children: [
{ id: 'total', header: 'Total', columnGroupShow: 'closed' },
{ id: 'base', header: 'Base', columnGroupShow: 'open' },
{ id: 'bonus', header: 'Bonus', columnGroupShow: 'open' }
]
}
]The toggle sits at the trailing edge of the group's own header cell. A
keyboard reaches it too: the header levels are part of the roving focus, so
ArrowUp from a leaf header walks up into the groups above it, ArrowLeft
and ArrowRight step between the groups of a level, Enter or Space folds
the one under the caret, and ArrowDown comes back. A column with no group
above it has nowhere to go up to. The same action is also in the column menu
of every column in the group. From code it is grid.api.toggleGroup(groupId)
and
grid.api.setGroupCollapsed(groupId, collapsed), which announce and emit
columnGroupToggled the way every other column operation does.
headerGroupCell draws the group header yourself, the way headerCell draws
a leaf one. The snippet is handed the group cell - id, label, span, whether it
is folded - and a toggle, and the grid's own control stays beside whatever
it draws:
{#snippet payHeader({ cell, toggle }: HeaderGroupContext)}
<Badge label={`${cell.header} (${cell.span})`} onclick={toggle} />
{/snippet}
``` Folding is not hiding -
what the Column chooser put away stays away, and what a group folded comes
back when it opens - and the state travels in a snapshot, keyed by group.
A group is only offered a toggle when the state it would switch to leaves a
column of it on screen. One whose children are all `'open'` would fold its own
header away with them, and nothing would be left to click to bring it back.
### Folding to a rail
`collapseMode: 'rail'` folds the other way: the whole group goes, header and
cells alike, and a narrow drawer stands in its place with the group's name down
its length. It runs the full height of the grid, header included, and the name
starts at the top of the header and stays there as the rows scroll.
```ts
{
id: 'planning',
header: 'Planning',
collapseMode: 'rail',
children: [target, variance] // no `columnGroupShow` needed
}
```
Nothing has to declare `columnGroupShow` for this: the drawer is what folds the
group back open, so a group with no summary column can still fold. Clicking
anywhere down it opens the group again, which is why no toggle sits in the
header over it. The drawer holds no data, so it is not exported, copied or
filtered on, and the group's own header cell is still there underneath it,
named and marked collapsed, which is how the keyboard reaches it. A pinned
group folds to a pinned drawer, which stays at its edge while the rest scrolls.
The `/groups/cases` page walks ten of these: four levels of nesting, a drawer
inside another group, two drawers side by side, pinned drawers on both edges,
pinned rows, virtualized rows and columns, a group with nothing to fold into,
a grid that opens folded and survives a snapshot round trip, right to left,
and an export taken while folded.
A group's `id` shares a namespace with the columns', so it needs one of its
own: a group and a column that answer to the same id cannot both be addressed.
## Localization
Hand the grid the languages it may use. It picks one from the page's own
language, so nothing else needs configuring:
```ts
import { enUS, jaJP, viVN } from '@sv5ui/datagrid/locales'
createDataGrid({ columns, data, getRowId, locales: [enUS, viVN, jaJP] })Twelve packs ship: en-US, vi-VN, zh-CN, ja-JP, ko-KR, fr-FR,
de-DE, es-ES, pt-BR, ru-RU, id-ID, th-TH. Only what you import is
bundled.
localeforces a tag. Assigninggrid.localeswitches in place, keeping the sort, filter and selection on screen.- The same tag drives
Intl, so number, currency and date columns that name no locale of their own follow the grid and reformat with it. - A tag nobody answers for falls back to English.
viis answered byvi-VN. labelsandannounceroverride single strings on top of the chosen pack.
A pack is { tag, labels, announcer } and says what it has. English answers
for the rest, so a pack of five strings works, and a pack written against one
version keeps working when the next names a string it has never heard of: that
string arrives in English rather than as a blank or as a build that will not
run. Every key is typed, so what a pack does say is checked.
<DataGrid {grid} persistState={{ key: 'orders-grid' }} />Column layout, sort, filter, page size and density are mirrored into
localStorage and restored before the first paint. grid.api.getState() and
grid.api.setState(snapshot) do the same by hand. The snapshot is versioned and
JSON-serializable, so it travels to a server or a URL just as well.
Columns are keyed by id: ids that disappeared are dropped, ids added since keep
their defaults. Pass migrate to upgrade snapshots written by an older version
of your app. Anything it declines falls back to the column defaults rather than
half-applying. Features persist their own slice through serialize and
hydrate.
Restoring reads
localStorage, which the server cannot. Render a persisted grid client-side (export const ssr = false) to avoid a flash of the default layout on reload.
rowModel: 'server' tells the pipeline that data already holds exactly what
should be shown: filtering, sorting and windowing pass their rows through
untouched. The features stay registered, because their state, UI and events are
what a server row model listens to.
import {
getFiltering,
getPagination,
getSorting,
toFilterRequest,
toSortRequest
} from '@sv5ui/datagrid'
const grid = createDataGrid({
columns,
data: [],
getRowId,
rowModel: 'server',
features: [sorting(), filtering(), pagination({ pageSize: 25 })]
})
for (const event of ['sortChanged', 'filterChanged', 'pageChanged'] as const) {
grid.events.on(event, fetchPage)
}
let inFlight = 0
async function fetchPage() {
const ticket = ++inFlight
const { rows, total } = await api.load({
filter: toFilterRequest(
getFiltering(grid)!.model,
grid.columns.visible.map((column) => column.id)
),
sort: toSortRequest(getSorting(grid)!.sort, grid.columns.defs, getSorting(grid)!.nulls)
})
// Typing into the quick filter sends a request per keystroke, and they do
// not come back in the order they left. Without this the grid ends up
// showing the answer to a question the user has already moved on from.
if (ticket !== inFlight) return
grid.data = rows
getPagination(grid)!.setRowCount(total)
}The events fire after the feature has settled, which is what makes reading the
state inside the handler safe: pageChanged reports the page the grid moved
to rather than the one that was asked for, and a sort or a filter has already
reset the page to 1 by the time it reaches you. setRowCount pulls the page
back inside a list that shrank, and a selection survives data being replaced,
so a row picked on page 1 is still picked when the user returns to it.
exportCsv writes the rows the grid is holding. Under a client row model that
is every row your filter left, which is what the toolbar's "All rows" means.
Under rowModel: 'server' the grid holds one page, so the same item writes
that page and is named "Loaded rows" instead of promising the rest.
For the whole set, export on the server. A browser cannot be handed ten million rows to turn into a file: the string alone outgrows the tab long before the download starts, and the rows would have to be fetched page by page first. Point the item at an endpoint that streams one:
<Grid.ExportMenu onExportAll={() => (location.href = `/api/orders.csv?${query}`)} />Build query from the same request the grid sends, so the file matches what is
on screen:
const query = new URLSearchParams({
filter: JSON.stringify(toFilterRequest(getFiltering(grid)!.model, visibleIds)),
sort: JSON.stringify(toSortRequest(getSorting(grid)!.sort, grid.columns.defs))
})Selection is unaffected: selected ids are held across pages, so "Selected rows" writes what the user picked whichever model is in use.
The request carries everything the grid decided, and your backend decides the
rest. Under rowModel: 'server' the grid does not filter or sort what it is
handed, so where the two disagree, what the reader sees is yours:
| The grid means | A database's default |
|---|---|
Text compares case-insensitively unless caseSensitive is set |
LIKE is case-sensitive in Postgres |
Text orders naturally, so Item 2 precedes Item 10 |
Item 10 precedes Item 2 |
Blank is null, undefined or '' |
IS NULL misses '' |
between includes both ends |
varies |
| A date condition means a calendar day, in the reader's own zone | a timestamp in the database's zone |
A percent column holds the ratio, so 5% travels as 0.05 |
whatever the column stores |
nulls rides on every sort entry, written as the side blanks actually land on,
so ORDER BY ... NULLS LAST reproduces it without further thought. quickFields
names the columns a bare query applies to, which is otherwise unguessable.
Two things the request cannot carry, because they are functions: a column's
sortFn and a filter's custom predicate run on the client only. Under a
server row model they are never called, and the request describes the built-in
meaning of the condition instead.
src/tests/server-contract.test.ts is a backend written against this table.
It answers what the client answers, and it is the shortest description of what
conformance costs.
toFilterRequest and toSortRequest produce normalized wire shapes, kept
deliberately separate from the internal models: the models answer to the UI and
change with it, while these answer to your backend and grow only by adding a
field a backend could not otherwise know, the way nulls and quickFields
were added. A column's sortField is what travels, so an id that is a UI
concern need not be one your database recognizes.
Every visual slot is overridable, app-wide or per grid:
import { defineDataGridConfig } from '@sv5ui/datagrid'
defineDataGridConfig({
defaultVariants: { density: 'compact' },
slots: { cell: 'font-mono', headerCell: 'uppercase tracking-wide' }
})<DataGrid {grid} ui={{ row: 'even:bg-surface-container-lowest' }} />cellClass and rowClass cover data-driven styling. Density (compact,
standard, comfortable) drives row height and padding through CSS variables.
-
A div-based ARIA
grid, ortreegridonce rows nest. -
One tab stop. Cells carry a roving tabindex and every control inside answers through it, so leaving a thousand-row grid takes one press.
-
The whole keyboard surface, and every binding a feature adds is listed with the feature that adds it:
Keys What they do Arrows, Home/End,PageUp/PageDownMove the focused cell Ctrl+Home/Ctrl+EndFirst and last cell of the grid Enteron a headerCycle that column's sort Shift+Enteron a headerAdd the column to a multi-sort Space/Shift+Space/Ctrl+ASelect a row, a range, everything on the page Ctrl+C/Ctrl+VCopy as TSV, paste across cells Enter,F2, or any printable keyOpen the editor, seeded with what was typed EscapeClose what the editor opened, then the editor Ctrl/Cmd+EnterCommit without leaving the cell, for a textarea or tags field that owns EnterCtrl+Z/Ctrl+Shift+Z/Ctrl+YUndo and redo an edit Shift+←/Shift+→Resize the focused column Alt+←/Alt+→Move the focused column Alt+↓on a headerOpen the column menu Alt+↑/Alt+↓on a row gripMove the row ←/→/Enteron a nested rowCollapse, expand, or step to the parent Paste is bound to the paste event rather than to
Ctrl+V, so it reads the clipboard without a permission prompt and covers a right-click paste too. -
A polite live region announces sorting, filtering, paging, selection, column changes and row moves, in the grid's language.
-
Layout uses logical properties, so
dir="rtl"mirrors the grid, pinned columns included. Horizontal scroll is normalized throughscrollStartandsetScrollStart, since browsers reportscrollLeftas negative under RTL. -
Every demo route is asserted axe-clean in CI.
Measured on Chromium at a 1500x950 viewport, 39 columns of mixed renderers
(currency, percent, date, badge, progress, rating, boolean). The playground
route /stress is where these come from, so they can be re-run rather than
taken on trust.
| 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 |
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. The heap is your data, not the grid's overhead.
Sorting and filtering are measured separately, by pnpm bench, because they
are arithmetic rather than rendering and a browser adds noise to them. 100k
rows, four columns, best of ten:
| Operation | Time |
|---|---|
| Sort by number | 18ms |
| Sort by string | 265ms |
| Multi-sort, string and number | 264ms |
| Quick filter, per keystroke | 6ms |
| Build row nodes | 2ms |
A string sort is slower than a numeric one by an order of magnitude and stays
that way: most of it is Intl.Collator, which is what makes Item 2 sort
before Item 10, and the grid does not trade that away for speed.
-
A very wide grid costs its column list, not its columns. Only the columns in view are rendered, bounded even on the first paint before the container has been measured, so the cells drawn stay flat as columns are added. What does grow is the arithmetic behind them and the CSS grid template every row declares: measured in a browser at 100 rows, 20,000 columns mounts in about 300ms, of which the template is 117KB per row. Thousands of columns are comfortable; tens of thousands work and are worth measuring for your own widths.
-
Scrolling holds 60fps to roughly half a million rows and falls to about 28fps at a million with this many columns. Fewer columns move that line out.
-
Quick filter pays for its first keystroke and reuses it after. The first pass is O(rows x visible columns) on the main thread and formats every cell, since the filter matches what a cell draws rather than the value behind it. Each keystroke after that is one substring test per row against text already built. Measured on the bench at a million rows across four columns: 2.5s for the first keystroke, 180ms for each one after, at roughly 7MB of held text per 100k rows. Filter on fewer columns, or use
rowModel: 'server', where that first pass is what matters.The text is held against the row object, so it is dropped when a row is edited, when
datais replaced, and when the visible columns or the language change. -
Beyond the browser's maximum element height the scroll range is scaled rather than clamped, so the last row stays reachable; a pixel of scrolling simply covers more than a pixel of content. Engines differ on where that starts (Chromium at 2^25px, others lower), so the grid caps below the lowest in wide use.
-
getRowHeight: 'auto'switches the virtualizer to a Fenwick-tree offset cache, which is O(log n) per lookup rather than the fixed path's arithmetic. Prefer a fixed height when the rows allow it. -
Row reorder rewrites
data, so an active sort re-sorts it immediately. Clear the sort before offering the grip.
Body cells carry data-dg-cell="rowIndex:colIndex", holding absolute indices
within the filtered and sorted set, and rows carry data-dg-row-id. Both are
public: delegate pointer events from a wrapper and read them with
event.target.closest('[data-dg-cell]') rather than attaching a handler per
cell.
The two rows above the body use negative indices on the same attribute: -1
for the leaf header row and -2 for the filter row. A row index below zero is
therefore never a data row. A header group spans columns, so it cannot be
named by a column index at all: its cells carry
data-dg-header-cell="level:firstColumn" instead, counted from the topmost
level.
Everything exported from the package root is public and covered by semver from 1.0 on. The surface is deliberately small: enough to render a grid, enough to write a feature module, and nothing else. Internal helpers such as pipeline transforms, filter compilation, undo plumbing, column sizing maths and scroll normalization stay unexported and change freely between releases.
Classes the grid constructs for you are exported as types only. You reach an
instance through the grid or a getX(grid) accessor.
If you need something unexported in order to build a feature, that is a gap in
the extension points. Open an issue rather than reaching into dist, and the
extension point gets fixed.
Issues and pull requests are welcome. To run the project locally:
git clone https://github.com/ndlabdev/sv5ui-datagrid.git
cd sv5ui-datagrid
pnpm install
pnpm dev # playground at localhost:5173
pnpm test # vitest (unit + browser)
pnpm check # svelte-check
pnpm lint # prettier + eslint
pnpm build # package + publintThe playground under src/routes has one page per feature, plus review routes:
qa runs everything in one grid, i18n switches language in place, export
shows the bytes a CSV export produces, spans exercises cell spanning against a
pinned column, editors puts every editor beside its validation rule, and
stress loads up to a million rows across 39 columns.