Advanced

Headless Core

createDataGrid returns state and a derived pipeline, and nothing else. No DOM, no styling, no component to mount. The component layer is one consumer of it, and your own markup can be another.

Creating a Grid

Reading the grid is reading runes, so a derived value tracks it without a subscription and without a store. The three badges below are three $derived lines over the same instance. Sort a column, or add a row, and they follow.

data is $state.raw, so the button assigns a new array rather than pushing onto the old one. A push would change nothing on screen.

6 rows first: Hoang Kowalski unsorted
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
import { createDataGrid, filtering, sorting } from '@sv5ui/datagrid';

// No component involved: this is state and a derived pipeline.
const grid = createDataGrid<Person>({
  columns,
  data: people,
  getRowId: (person) => String(person.id),
  features: [sorting(), filtering()]
});

// Reading it is reading runes, so a derived value tracks it
let count = $derived(grid.totalRows);
let firstOnScreen = $derived(grid.nodes[0]?.row.name);

Without the Component Layer

The list below is a plain ul. It has no grid markup, no CSS from the package and no DataGrid component, yet filtering, sorting and paging all work, because those are pipeline stages rather than UI.

40 match page 1 of 8
  • Hoang Kowalski Design
  • Bruno Nguyen Growth
  • Bruno Dubois Design
  • Farid Haddad Growth
  • Jonas Yilmaz Core
<script lang="ts">
  // The core does not need the component layer at all. Here the same grid
  // drives a plain list: filtering, sorting and paging still work, and
  // nothing about the DOM comes from the package.
  const grid = createDataGrid<Person>({
    columns,
    data: people,
    getRowId,
    features: [sorting(), filtering(), pagination({ pageSize: 5 })]
  });
</script>

<input oninput={(e) => getFiltering(grid)?.setQuickFilter(e.currentTarget.value)} />

<ul>
  {#each grid.nodes as node (node.id)}
    <li>{node.row.name} - {node.row.team}</li>
  {/each}
</ul>

<button onclick={() => getPagination(grid)?.setPage(2)}>Page 2</button>

The Row Pipeline

A RowNode is { id, row, index, meta? }: the raw row, its identity and its position. The grid exposes three points along the pipeline, and the numbers below are those three. Filter the list and page it to watch them come apart.

sourceNodes 40 preWindowNodes 40 nodes 5 totalRows 40
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
1–5 of 40
// Four points along the chain, each a rune
grid.sourceNodes;      // before filtering
grid.preWindowNodes;   // filtered and sorted, before paging or virtualizing
grid.nodes;            // exactly what is on screen
grid.totalRows;        // preWindowNodes.length

// A RowNode is the unit the pipeline moves
// { id, row, index, meta? }
for (const node of grid.preWindowNodes) {
  console.log(node.id, node.index, node.row.name);
}

Composing the Chrome

Grid.Root sets the context the other parts read, so it wraps them all. Beyond that nothing is mandatory: leave a part out and it is simply not there, put your own markup between them and it stays.

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>

<!-- Root sets the context every other part reads, so it wraps them all.
     Nothing else is mandatory: drop a part and it is simply not there. -->
<Grid.Root {grid} class="space-y-3">
  <Grid.Toolbar>
    <Grid.QuickFilter placeholder="Search" debounce={300} />
    <Grid.FilterChips />
    <div class="ms-auto flex gap-2">
      <Grid.ExportMenu filename="rows.csv" />
      <Grid.ColumnChooser />
      <Grid.DensityToggle />
    </div>
  </Grid.Toolbar>

  <Grid.ContextMenu>
    <Grid.Viewport class="h-[420px]">
      <Grid.Header />
      <Grid.Body emptyText="Nothing here" />
    </Grid.Viewport>
  </Grid.ContextMenu>

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

Events

Fifteen entries, each with its own payload shape. A handler is typed from the name alone, so the payload needs no annotation. The Quick Start page logs them live against a grid you can drive.

EventPayload
sortChanged{ sort: SortState[] }
filterChanged{ filter: FilterModel }
pageChanged{ page: number, pageSize: number | null }
rowCountChanged{ total: number }
columnResized{ columnId: string, width: number }
columnMoved{ columnId: string, toIndex: number }
columnPinned{ columnId: string, side: PinnedSide | null }
columnVisibilityChanged{ columnId: string, hidden: boolean }
selectionChanged{ selectedIds: string[] }
rowsCopied{ count: number }
rowExpanded{ id: string, expanded: boolean }
rowPinnedChanged{ id: string, side: RowPinSide | null }
rowMoved{ id: string, from: number, to: number }
cellEdited{ rowId: string, columnId: string, oldValue: unknown, newValue: unknown }
rowEdited{ rowId: string, changes: Record<string, unknown> }
// The typed bus. on returns nothing; keep the handler if you need to stop.
grid.events.on('sortChanged', ({ sort }) => {});
grid.events.on('filterChanged', ({ filter }) => {});
grid.events.on('pageChanged', ({ page, pageSize }) => {});
grid.events.on('selectionChanged', ({ selectedIds }) => {});
grid.events.on('cellEdited', ({ rowId, columnId, oldValue, newValue }) => {});
grid.events.on('rowEdited', ({ rowId, changes }) => {});
grid.events.on('columnResized', ({ columnId, width }) => {});
grid.events.on('columnMoved', ({ columnId, toIndex }) => {});
grid.events.on('columnPinned', ({ columnId, side }) => {});
grid.events.on('columnVisibilityChanged', ({ columnId, hidden }) => {});
grid.events.on('rowsCopied', ({ count }) => {});
grid.events.on('rowExpanded', ({ id, expanded }) => {});
grid.events.on('rowPinnedChanged', ({ id, side }) => {});
grid.events.on('rowMoved', ({ id, from, to }) => {});

GridState

What createDataGrid returns. data, density and locale are writable; the rest is read-only.

MemberDescription
dataThe rows. $state.raw, so assign a new array rather than mutating
densityRow height and padding, writable
localeThe BCP-47 tag in force, writable
columnsDefinitions, visible columns, widths, order and overrides, plus the header groups: groupDef, isCollapsed, isRail, foldableGroupOf, setGroupCollapsed and toggleGroup
focusThe active cell and the keybindings features contributed
expansionWhich rows are expanded, plus the enabled flag the viewport reads to render role="treegrid" rather than role="grid"
announcerThe live region the grid speaks through
eventsThe typed bus
labelsEvery string the chrome renders, merged from the locale pack
rowModelWhere filtering, sorting and windowing happen
apigetState and setState, plus what each feature contributed
nodes / preWindowNodes / sourceNodesThree points along the pipeline
totalRowspreWindowNodes.length, which is what a footer counts
nodeById(id)Resolved against the unfiltered source
getValue(node, column, purpose?)The cell value, through accessor or row[id], and through any cellValue gate standing in front of that column. The default purpose is 'render', which is what the cell draws
readerFor(id, purpose?)The gate itself, so a pass over many rows asks once and then loops rather than asking per cell
feature(id)Untyped lookup by feature id

Grid Parts

Every part of the component layer, all optional except Root.

PartWhat it renders
Grid.RootSets the context every other part reads. Takes grid, ui and persistState
Grid.ViewportThe scroller carrying role="grid" and the keyboard handlers
Grid.HeaderThe sticky header, including nested group rows
Grid.BodyThe rows, plus the loading, empty and error surfaces
Grid.ToolbarA row above the viewport; you place what goes in it
Grid.QuickFilterThe search input, with its own debounce prop
Grid.FilterChipsOne removable chip per active column filter
Grid.ColumnChooserThe visibility menu
Grid.DensityToggleThe three-way density control
Grid.ExportMenuCopy and CSV export, selection or all rows
Grid.ContextMenuWraps the viewport and carries what features contribute
Grid.StatusBarRow counts and the selection summary
Grid.PaginationThe footer, with pageSizes