Advanced

Custom Features

A feature is a plain object with optional hooks. The nine that ship use nothing that is not available to yours, so anything they can do, your own can do the same way.

The Shape

Only id is required, and a feature that defines one hook costs one hook. The two grids below are the same rows and the same columns. The second registers a feature of two lines, an id and a cellDecoration, and that is the whole difference.

no features
Name
Status
Salary
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
6 rows
one feature, one hook
Name
Status
Salary
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
6 rows
import type { GridFeature } from '@sv5ui/datagrid';

// A feature is a plain object. Every hook is optional, and the built-in
// features use nothing that is not available here.
const myFeature = <TRow,>(): GridFeature<TRow> => ({
  id: 'my-feature',          // also the key of its state on grid.state

  pipelineStage: { order, transform },   // an ordered, pure row transform
  createState: (grid) => new MyState(grid),
  createApi: (grid) => ({ myMethod: () => {} }),
  keybindings: [{ key: 'Ctrl+k', when, handler }],
  menuItems: (ctx) => [],
  cellDecoration: (ctx) => ({ class: '', selected: false }),
  serialize: (grid) => sliceForSnapshot,
  hydrate: (slice, grid) => {}
});

Decorating Cells

Six lines, and salaries over 120,000 are picked out wherever they are. This is where a feature paints, and it is asked per rendered cell, so it wants to be cheap.

Status
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
10 rows
import type { GridFeature } from '@sv5ui/datagrid';

// The smallest useful feature: a class on the cells that deserve one.
const highlightHighEarners = (): GridFeature<Person> => ({
  id: 'highlight-high-earners',
  cellDecoration: ({ node, column }) =>
    column.id === 'salary' && node.row.salary > 120000
      ? { class: 'bg-success/10 font-semibold text-success' }
      : undefined
});

createDataGrid<Person>({ columns, data, getRowId, features: [highlightHighEarners()] });

// cellDecoration runs for every rendered cell, so keep it cheap. A grid
// whose features do not define it skips the work entirely.

Styling From the Value Itself

A class can only name a shade someone thought of in advance. When the value is the scale, the decoration carries style instead: a record keyed by CSS property, custom properties included, which is how a feature reaches a pseudo-element. The Salary column below is tinted by how large it is rather than by which of three buckets it falls in.

Name
Salary
Team
Hoang Kowalski
$127,691.00
Design
Bruno Nguyen
$105,146.00
Growth
Bruno Dubois
$86,538.00
Design
Farid Haddad
$76,443.00
Growth
Jonas Yilmaz
$129,812.00
Core
Quyen Tanaka
$72,011.00
Growth
Mai Nguyen
$83,226.00
Support
Bruno Novak
$71,507.00
Support
Grace Dubois
$94,212.00
Support
Tuan Ivanov
$105,520.00
Design
10 rows
// A class can only name a value someone thought of in advance. The scale
// below is the number itself, so it goes on the cell's own style.
const heat = (max: number): GridFeature<Person> => ({
  id: 'heat',
  cellDecoration: ({ node, column }) =>
    column.id === 'salary'
      ? {
          style: {
            'background-color': `color-mix(in oklab, var(--color-primary) ${Math.round(
              (node.row.salary / max) * 45
            )}%, transparent)`
          }
        }
      : undefined
});

// Keyed by CSS property, custom properties included, which is how a
// feature reaches a pseudo-element:
{ style: { '--dg-bar': '62%' } }

// Several features decorating one cell merge per property, the later one
// winning, the same way their classes stack. A key that is not a CSS
// property is dropped and a value is cut at the first semicolon, so one
// entry stays one declaration and a colour read out of row data cannot
// open a second.

// The grid writes its own layout as style directives, which outrank the
// attribute: a decoration can paint a cell, but it cannot move it out of
// its column or unpin it.

Several features decorating one cell merge per property, the later one winning, the same way their classes stack. A key that is not a CSS property is dropped and a value is cut at the first semicolon, so one entry stays one declaration and a colour read out of row data cannot open a second. The grid writes its own layout as style directives, which outrank the attribute, so a decoration can paint a cell but cannot move it out of its column or unpin it.

Gating a Cell's Value

A decoration paints 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. Masking what is drawn and nothing else is the mistake it exists to prevent.

The hook is asked per column and per purpose rather than per value, so a pass that reads a whole column asks once and then loops. Turn the switch below and watch all six purposes change together; the table under the grid is read from the grid itself with grid.getValue(node, column, purpose).

gated
Name
Salary
Team
Hoang Kowalski
***
Design
Bruno Nguyen
***
Growth
Bruno Dubois
***
Design
Farid Haddad
***
Growth
Jonas Yilmaz
***
Core
Quyen Tanaka
***
Growth
Mai Nguyen
***
Support
Bruno Novak
***
Support
Grace Dubois
***
Support
Tuan Ivanov
***
Design
10 rows
PurposeWhat the first row's Salary looks like
render***
export***
clipboard***
search***
facet***
edit***

Salary is editable in this grid. Double-click it with the mask on and nothing opens: an editor seeded with a substitute would commit it over the real data.

// cellValue stands between a cell and every way the grid lets it out.
const maskedReader = () => '***';

const maskSalary = (masked: () => boolean): GridFeature<Person> => ({
  id: 'mask-salary',
  // Asked per column and per purpose, not per value: the passes that read
  // a whole column at a time ask once and then loop over the rows.
  cellValue: ({ column }) => (column.id === 'salary' && masked() ? maskedReader : undefined)
});

// The purposes are every way out: 'render' | 'export' | 'clipboard' |
// 'search' | 'facet' | 'edit'. Masking render alone would leave the value
// in the clipboard, in the CSV and in the text a quick filter searches.

// Read one yourself when you need to know what a purpose sees:
grid.getValue(node, column);             // what the cell draws
grid.getValue(node, column, 'export');   // what a CSV would carry
grid.readerFor('salary', 'export');      // the reader itself, to hoist out of a loop

// Hand the same reader back each time. The grid compares by identity to
// tell a substituted cell from an untouched one, and the quick filter's
// text and the set filter's value list are cached per column and keyed by
// the reader: a fresh closure per call throws both caches away.

Hand the same reader back each time. The grid compares by identity to tell a substituted cell from an untouched one, and the quick filter's text and the set filter's value list are held per column and keyed by the reader, so a fresh closure per call throws both caches away. The library measures that at 5ms against 71ms over a hundred thousand rows.

// Answer in the type the column draws. A built-in renderer formats what
// it is handed, so '***' on a currency column parses as no number and the
// cell draws empty; null draws the column's empty text. A mark of your own
// needs an untyped column or a cell snippet.

// A cell the reader substitutes is one the grid refuses to edit: an editor
// opened on a value the user is not being shown would commit the
// substitute over the real data.

// Two things it deliberately does not cover:
{ id: 'salary', sortable: true, filter: 'number' }   // take both off a masked column

// Sorting reads 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.

// 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.

Adding a Pipeline Stage

A stage decides which rows exist downstream of it. This one sits just after the filter stage and reads a rune, which is what makes the grid re-run it when the switch moves.

10 rows
Status
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
10 rows
import { PIPELINE_ORDER, type GridFeature } from '@sv5ui/datagrid';

// A stage is a pure transform of RowNode[], inserted at a declared order:
//   filter 100, sort 200, group 300, flatten 400, pin-split 500, window 900
const onlyActive = (enabled: () => boolean): GridFeature<Person> => ({
  id: 'only-active',
  pipelineStage: {
    order: PIPELINE_ORDER.filter + 1,   // just after the filter stage
    transform: (nodes) =>
      enabled() ? nodes.filter((node) => node.row.status === 'active') : nodes
  }
});

// Reading a rune inside transform is what makes the stage re-run: the
// pipeline memoizes each link with $derived, so an unchanged input is an
// unchanged output.

Shaping the Columns

A pipeline stage decides which rows exist. columns does the same for columns: it is handed the definitions and answers the list the grid should draw. Everything downstream is built from what it returns, so a column a feature adds is a column like any other. It takes a width, it sorts, it exports, and it has a place in a snapshot.

The feature below swaps Status for a column of its own when it is switched on, built from three fields no column was declared for. It hides the column it replaces rather than dropping it, which keeps it filterable. This is the hook pivot() is built on: its columns come from the data, which nothing but a feature could know.

3 columns drawn
Status
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
10 rows
import type { GridFeature, GridState } from '@sv5ui/datagrid';

const SUMMARY = 'summary';

class Summary {
  on = $state(false);
}

const getSummary = (grid: GridState<Person>) => grid.feature<Summary>(SUMMARY);

const KEPT = new Set(['name', 'salary']);

const summary = (): GridFeature<Person> => ({
  id: SUMMARY,
  createState: () => new Summary(),

  // Handed the definitions, answers the list the grid should draw.
  columns: (defs, grid) => {
    // Reading the rune here is what makes the grid ask again when it moves.
    if (!getSummary(grid)?.on) return defs;

    return [
      // Hidden rather than dropped, so they still filter.
      ...defs.map((def) => (KEPT.has(def.id) ? def : { ...def, hidden: true })),
      // And one of its own, which is a column like any other.
      {
        id: 'summary-where',
        header: 'Where',
        accessor: (person: Person) => `${person.role}, ${person.team}, ${person.country}`
      }
    ];
  }
});

Three rules follow from where it runs. It is read inside a derived, so it re-runs whenever a rune it reads changes, and must be pure and cheap. Features are asked in registration order, each handed what the one before returned. And grid.columns.defs stays what the application wrote, so a feature can always find the original definitions there. Answer the definitions untouched to change nothing.

State, Menus and Keys

The feature below owns a set of flagged rows, paints them, contributes a context-menu entry and binds a key. Right-click a row and choose Toggle flag, or focus a row and press Ctrl+F.

0 flagged
Status
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
10 rows
import { type GridFeature, type GridState } from '@sv5ui/datagrid';

const FLAGS = 'flags';

class Flags<TRow> {
  flagged = $state.raw<ReadonlySet<string>>(new Set());

  constructor(private grid: GridState<TRow>) {}

  toggle = (id: string) => {
    const next = new Set(this.flagged);
    next.has(id) ? next.delete(id) : next.add(id);
    this.flagged = next;
  };
}

// Declare what the feature adds to grid.api, from the feature's own module.
declare module '@sv5ui/datagrid' {
  interface GridApi {
    toggleFlag?: (id: string) => void;
  }
}

export const flags = <TRow,>(): GridFeature<TRow> => ({
  id: FLAGS,
  createState: (grid) => new Flags(grid),
  createApi: (grid) => ({ toggleFlag: getFlags(grid)!.toggle }),
  cellDecoration: ({ grid, node }) =>
    getFlags(grid)?.flagged.has(node.id) ? { class: 'bg-warning/12' } : undefined
});

// The accessor is the typed path, the same shape the built-ins ship.
export const getFlags = <TRow,>(grid: GridState<TRow>) =>
  grid.feature<Flags<TRow>>(FLAGS);

menuItems is asked by two menus. The row context menu calls it with node set, and every column menu calls it with columnId set. Check which one you were handed and answer an empty array for the other, as this feature does. Entries returned without looking appear in both places, on every column. The column menu is drawn by columnOps(), so column entries need it registered.

Menu Items and Keybindings

A when that returns false lets a later binding claim the same key, which is how two features can share one shortcut without knowing about each other. The lookup takes the first binding whose key matches and whose guard passes, in registration order.

This grid registers two features, both on Ctrl+e. The first only wants the Salary column. Click a Salary cell and press it, then click a Name cell and press it again: the same key reaches a different feature.

Nothing yet. Click a cell, then press Ctrl+e.

Name
Status
Salary
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
6 rows
const flags = <TRow,>(): GridFeature<TRow> => ({
  id: 'flags',

  // Context menu entries. ctx carries the grid, and the row or column the
  // menu was opened on.
  menuItems: ({ grid, node }) =>
    node
      ? [
          {
            id: 'toggle-flag',
            label: 'Toggle flag',
            icon: 'lucide:flag',
            onSelect: () => getFlags(grid)?.toggle(node.id)
          }
        ]
      : [],

  // Keybindings, with a guard. Returning false from when lets a later
  // binding claim the same key.
  keybindings: [
    {
      key: 'Ctrl+f',
      when: (grid) => grid.focus.active.row >= 0,
      handler: (grid) => {
        const node = grid.preWindowNodes[grid.focus.active.row];
        if (node) getFlags(grid)?.toggle(node.id);
      }
    }
  ]
});

Joining the Snapshot

Add serialize and hydrate to the flags feature and the flags travel with the layout. Features are keyed by id inside the snapshot, so one added after a snapshot was written starts fresh rather than reading someone else's slice.

Right-click a row and toggle a flag or two, save, clear them, then restore. The flags come back, and you can see them in the snapshot under their feature id.

When a snapshot is applied, every feature hydrates before the column part is restored. A feature that shapes the columns therefore has them in place by the time their order, widths and folded groups are put back.

0 flagged
Status
Hoang Kowalski
invited
$127,691.00
Bruno Nguyen
invited
$105,146.00
Bruno Dubois
active
$86,538.00
Farid Haddad
suspended
$76,443.00
Jonas Yilmaz
active
$129,812.00
Quyen Tanaka
suspended
$72,011.00
Mai Nguyen
invited
$83,226.00
Bruno Novak
active
$71,507.00
Grace Dubois
active
$94,212.00
Tuan Ivanov
active
$105,520.00
10 rows
const flags = <TRow,>(): GridFeature<TRow> => ({
  id: 'flags',

  // The feature's slice of a state snapshot. Return undefined and it stays
  // out of the snapshot entirely.
  serialize: (grid) => {
    const ids = [...(getFlags(grid)?.flagged ?? [])];
    return ids.length > 0 ? ids : undefined;
  },

  // Restores what serialize produced. A feature added after the snapshot
  // was written simply starts fresh.
  hydrate: (slice, grid) => {
    const state = getFlags(grid);
    if (state && Array.isArray(slice)) state.flagged = new Set(slice as string[]);
  }
});

// Both run through grid.api.getState() and setState(), and therefore
// through persistState too.

GridFeature Hooks

Every hook is optional except id.

HookWhat it does
idUnique feature id, and the key of its state on grid.state
pipelineStageAn ordered, pure transform of the row list
createStateReactive state the feature owns, reached through grid.feature(id)
createApiImperative methods merged into grid.api
keybindingsKey, an optional when guard, and a handler
menuItemsEntries for the row context menu and for each column menu. ctx carries the grid, and node from the first or columnId from the second
columnsReshapes the columns the grid draws. Asked in registration order, inside a derived, so it must be pure and cheap
cellDecorationPer-cell classes, inline declarations and aria-selected. Runs per rendered cell
cellValueStands between a cell and every way its value leaves the grid. Asked per drawn cell, so hand back the same reader each time: the quick filter's text and a set filter's value list are cached against it, and a fresh closure per call throws both away. undefined reads the column straight through
componentA component the grid mounts inside its own root, for the work that needs an effect context or the DOM: a listener on the grid element, a layer over the rows. Takes no props and reaches the grid through getGridContext and getGridElement. Naming it here rather than having DataGrid import it is what keeps a feature nobody registered out of the bundle
rowCountHow many data rows this feature is holding, when it is the one that decided the shape of the list. A nested tree keeps its children on the row rather than in data, so counting the pipeline would report only the roots. Return undefined to let the grid count for itself. This is the number the status bar shows and the number a screen reader is told after a filter, so the two cannot disagree
serializeThe feature's slice of a snapshot. undefined stays out
hydrateRestores what serialize produced

PIPELINE_ORDER

The orders the built-in stages occupy. Pick a number beside one to sit next to it.

ConstantOrderOccupied by
PIPELINE_ORDER.filter100Where filtering() sits
PIPELINE_ORDER.sort200Where sorting() sits
PIPELINE_ORDER.group300Reserved for grouping
PIPELINE_ORDER.flatten400Where a tree becomes a list
PIPELINE_ORDER.pinSplit500Where rowPinning() lifts its rows out
PIPELINE_ORDER.window900pagination() or virtualization(), never both