Customization

Theming

Every visual slot is overridable, for one grid or for the whole app, and density drives the geometry through CSS variables rather than through classes.

Per Grid

ui takes a class per slot. They are merged with the variant's own through tailwind-merge, so a conflicting utility replaces rather than fights.

Hoang Kowalski
Design
$127,691.00
Bruno Nguyen
Growth
$105,146.00
Bruno Dubois
Design
$86,538.00
Farid Haddad
Growth
$76,443.00
Jonas Yilmaz
Core
$129,812.00
Quyen Tanaka
Growth
$72,011.00
6 rows
<!-- ui overrides one grid, slot by slot. Classes are merged with the
     variant's own through tailwind-merge, so a conflicting utility wins
     rather than fighting. -->
<DataGrid
  {grid}
  ui={{
    viewport: 'rounded-xl border-primary/30',
    headerCell: 'uppercase tracking-wide text-primary',
    row: 'even:bg-surface-container-lowest',
    cell: 'font-mono'
  }}
/>

App-Wide Defaults

There is no live demo here on purpose: defineDataGridConfig is global, so calling it on this page would restyle every other demo in the section. The code is the whole of it.

import { defineDataGridConfig } from '@sv5ui/datagrid';

// App-wide defaults, in the same shape sv5ui components take. Grids read
// this when they mount, so call it once at startup rather than reactively.
defineDataGridConfig({
  defaultVariants: { density: 'compact' },
  slots: { headerCell: 'uppercase tracking-wide' }
});

// Order of application: variant classes, then this config, then the
// grid's own ui prop.

Classes land in three passes, each one merged over the last, so the narrowest wins:

PassWhere it comes fromReaches
1variant: 'bordered'The variant the grid was asked for
2defineDataGridConfig({ ui })Every grid in the app
3<DataGrid {ui} />This grid only

Density

Density is state on the grid rather than a class, and it writes two CSS variables that the row and cell slots read. Switch it below and watch the rows change height without a re-render of their content.

Hoang Kowalski
Design
$127,691.00
Bruno Nguyen
Growth
$105,146.00
Bruno Dubois
Design
$86,538.00
Farid Haddad
Growth
$76,443.00
Jonas Yilmaz
Core
$129,812.00
Quyen Tanaka
Growth
$72,011.00
6 rows
<!-- Density drives row height and cell padding through two CSS variables:
     --dg-row-h and --dg-cell-py.

       compact      2rem    0.25rem
       standard     2.5rem  0.5rem
       comfortable  3rem    0.75rem  -->

<DataGrid data={people} {columns} {getRowId} density="compact" />

<!-- On an instance, density is state you can set -->
<script lang="ts">
  const grid = createDataGrid<Person>({ data, columns, getRowId, density: 'comfortable' });
  grid.density = 'compact';
</script>

<!-- The toolbar's density toggle writes the same state -->
<Grid.DensityToggle />

Header Height

A header is as tall as a row until you say otherwise. headerHeight sets the leaf header row and groupHeaderHeight sets each tier of header groups above it, both in pixels. The second counts per tier rather than for the lot, so three tiers at 28 stand 84 tall. Leave either off and it follows the row height, which is to say it follows density.

Reach for the first when a header holds more than a label, such as a title wrapped onto two lines. Reach for the second when the tiers hold less: group labels are short, and a stack of them at full row height stands taller than the rows it describes. Rows pinned to the top and the filter row sit under whatever the header adds up to.

Work
Pay
Name
Team
Role
Salary
Hoang Kowalski
Design
Manager
$127,691.00
Bruno Nguyen
Growth
Support
$105,146.00
Bruno Dubois
Design
Manager
$86,538.00
Farid Haddad
Growth
Analyst
$76,443.00
Jonas Yilmaz
Core
Support
$129,812.00
Quyen Tanaka
Growth
Analyst
$72,011.00
6 rows
// Both are pixels, and both follow the row height when left off.
const grid = createDataGrid<Person>({
  data,
  columns,
  getRowId,
  headerHeight: 56,       // the leaf header row
  groupHeaderHeight: 28   // each tier of header groups, not the lot
});

// State on the grid, like density, so either can change later.
grid.headerHeight = 40;
grid.groupHeaderHeight = undefined; // back to following the rows

// The shorthand form takes the same two props:
//   <DataGrid data={people} {columns} {getRowId} headerHeight={56} />

// What they write, on the grid's root element:
//   --dg-header-h        read by the headerCell slot
//   --dg-group-header-h  read by the groupCell slot

Neither is written into a snapshot. Density is a preference a reader sets and expects to find again; a header height is a decision the page made about its own columns.

Data-Driven Classes

rowClass styles a whole row from its data, and cellClass one column's cells. Suspended rows are dimmed below, and salaries over 120,000 are picked out in the success colour.

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
// Per row, passed to createDataGrid or to DataGrid in the shorthand form
createDataGrid<Person>({
  columns,
  data,
  getRowId,
  rowClass: (node) => (node.row.status === 'suspended' ? 'opacity-60' : '')
});

// Per cell, on the column that owns it
{
  id: 'salary',
  type: 'currency',
  cellClass: ({ value }) => (Number(value) > 120000 ? 'font-semibold text-success' : '')
}

// Both run per render, so keep them cheap: no allocation, no lookups.

The DOM Contract

Three attributes are public and will not change under you: data-dg-cell holds the absolute row and column index within the filtered and sorted set, and data-dg-row-id holds the row id. The third names a header group and is described below. Delegate from a wrapper 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, so a row index below zero is never a data row. A header group spans columns and cannot be named by a column index at all, so its cells carry data-dg-header-cell="level:firstColumn" instead, counted from the topmost level.

One handler on the wrapper below reads both attributes off whatever cell you click. Nothing else in the markup is public: class names, element nesting and the transform the body uses are all free to change between releases. That includes every other data-dg-* marker you may find in the markup. They are internal structure and carry no promise.

nothing clicked yet
Hoang Kowalski
Design
$127,691.00
Bruno Nguyen
Growth
$105,146.00
Bruno Dubois
Design
$86,538.00
Farid Haddad
Growth
$76,443.00
Jonas Yilmaz
Core
$129,812.00
Quyen Tanaka
Growth
$72,011.00
6 rows
// Body cells carry their absolute position within the filtered and sorted
// set, and rows carry their id. Both are public, and so is the header
// group attribute further down. Every other data-dg-* marker is internal.
//
//   <div data-dg-cell="rowIndex:colIndex">
//   <div data-dg-row-id="42">

// The two rows above the body use negative indices on the same attribute:
//
//   -1  the leaf header row
//   -2  the filter row, when filtering({ floatingRow: true }) draws one
//
// so a row index below zero is never a data row.

// A header group spans columns, so a column index cannot name one. Its
// cells carry their own attribute instead, counted from the topmost level:
//
//   <div data-dg-header-cell="level:firstColumn">

// Delegate from a wrapper rather than attaching a handler per cell:
function onPointerDown(event: PointerEvent) {
  const cell = (event.target as HTMLElement).closest('[data-dg-cell]');
  if (!cell) return;
  const [rowIndex, colIndex] = cell.getAttribute('data-dg-cell').split(':').map(Number);
  const rowId = cell.closest('[data-dg-row-id]')?.getAttribute('data-dg-row-id');
}

Slots

Every key ui and defineDataGridConfig accept. A slot not listed here is internal. The last six style body cells rather than chrome: a feature paints them through cellDecoration as the grid renders, so they are only ever seen with that feature registered.

SlotWhat it styles
rootThe outermost element, which is where a virtual grid takes its height
toolbarThe toolbar row above the viewport
viewportThe scroller carrying role="grid" and the outer border
header / headerRow / headerCellThe sticky header group, its row and its cells
groupRow / groupCell / groupContentA nested header group row, its cells, and the box a headerGroupCell snippet draws into
groupCellFoldable / groupToggleRoom kept at the trailing edge of a group that can fold, and the control that sits in it
rail / railHead / railEdge / railSurfaceA group folded to a drawer: the strip down the body, its head over the header rows, the edges it closes itself with, and every cell standing over it
railInner / railLabel / railFocusWhat the drawer holds, the group's name turned to read up it, and how it marks focus without drawing a second box around a seam
sortButton / menuButton / resizeHandleThe controls inside a header cell
body / bodyOffset / row / cellThe body group, its transform, a row and a cell
cellFocus / cellEditing / cellEditor / cellErrorA focused cell, one being edited, its editor and its message
rowEditing / cellEditorInRow / cellEditorInRowWidget / cellEditorInRowDividerA row being edited: the ring around it, a text field inside it, a widget field inside it, and the hairline between two fields. A Select or a date field draws its own border and focus state, so the widget slot deliberately adds neither
cellEditorFlat / cellEditorPad / cellEditorWide / cellEditorField / cellEditableA text editor filling its cell, a widget editor padded inside one, an editor wider than its column, the field itself, and the hint on a cell that can be edited
pinnedCell / pinnedHeaderCell / pinnedRow / pinnedRowsTop / pinnedRowsBottomThe pinned sections: a pinned cell, its header, a pinned row and the two bands
stickyGroupRows / stickyGroupRowThe band stickyGroups pins under the header, and one line in it for each group the reader is inside
flashCellA cell flashCells() is marking. It carries the class the fade is attached to, so classes added here join every mark
pinnedCellRaised / pinnedCellSelectedA pinned cell over scrolled content, and one in a selected row
rowSelected / rowDragging / rowGhostSelection tint, the row being dragged and its lifted copy
rowSpanFill / rowSpanFillLast / rowSpanEdge / rowSpanEdgeStart / cellRowSpanA merged block, its last row, the edges it draws, the edge it opens with, and the cell that spans
headerControls / headerControlsPinned / headerDivider / groupBoundaryThe control cluster in a header cell, the same over a pinned column, the line between two headers, and the heavier line where a group ends
rowHandle / dropIndicator / rowDropIndicatorThe drag grip, and the two lines that say where a column or a row would land
chooserItem / toggleButton / tooltipTrigger / fullWidthCellA row in the column chooser, the expand toggle on a nested row, the wrapper an explicit tooltip attaches to, and the single cell a full-width row draws
filterRow / filterCell / filterCellPinned / filterSummaryThe filter row under the header, a cell of it, the same over a pinned column, and the text a column whose filter the row cannot hold reads back as
filterPanel / filterChips / statusBar / footerThe filter surfaces and the chrome under the grid
emptyThe no-rows surface
groupPanel / groupPanelLabel / groupPanelChip / groupPanelChipLabelThe drop area above the grid that grouping() groups from, its caption, and a chip naming one grouped column. Grid.PivotPanel draws its four zones with the same slots
groupPanelSeparator / groupPanelEmptyThe chevron between two chips, which reads as the nesting order, and the prompt shown while nothing is grouped
toolPanel / toolPanelCollapsed / toolPanelHeader / toolPanelTitle / toolPanelTabsThe side panel, the narrow bar it folds to, and its header, caption and tab strip. It is a column beside the grid from lg up and a full-width block below that
toolPanelBody / toolPanelRow / toolPanelRowLabel / toolPanelRowMutedThe scrolling list, one row of it, that row's label, and the dimming a row takes when what it offers does not apply
toolPanelValueRow / toolPanelPinMark / toolPanelHintA row on the values tab, which stacks a column name over its aggregation picker, the left or right mark on a pinned column, and the note under the list
filterBuilder / filterBuilderGroup / filterBuilderGroupHeader / filterBuilderRowThe condition tree advancedFilter() draws: the panel, a nested group, the row carrying that group's join and its controls, and one condition
filterBuilderColumnSelect / filterBuilderOperatorSelect / filterBuilderValueInputThe three controls of a condition. The two selects hold a fixed width so a tree of conditions lines up; the value field takes what is left
filterBuilderValuePlaceholder / filterBuilderHintThe stand-in shown where an operator such as blank needs no value, and the note under a group
findPanel / findCountThe find and replace bar, and its tally. The count is tabular so it does not shift as the numbers change while typing
formatPanel / formatPanelLabel / formatPanelEmpty / formatPanelErrorThe conditional formatting bar, its caption, the prompt shown with no rules, and the message an expression rule that will not parse reads back as
formatRuleChip / formatRuleChipLabel / formatRuleChipInvalidA chip naming one rule, its label, and the ring the chip takes when that rule cannot be applied
importPanel / importDropZone / importDropZoneActive / importHintThe import wizard, the area a file is dropped on, that area while a file is over it, and the note under it
importMapRow / importMapLabel / importMapSelectOne row of the mapping step: the column being mapped, its name, and the picker choosing which field of the file feeds it
importIssueList / importIssueRow / importIssueCountThe review step's list of what failed validation, one entry of it, and the tally beside it, which is tabular and coloured as an error
savedViews / savedViewsLabel / savedViewsModifiedThe saved views bar, its caption, and the mark saying the grid has moved since the open view was saved
rangeStatusBar / rangeStatusBarValueThe strip under the grid summing the selected range, and one figure in it
commandPaletteThe command palette dialog
rangeCell / rangeEdgeTop / rangeEdgeBottom / rangeEdgeStart / rangeEdgeEndA cell inside a selected range, and the four edges. Each edge is drawn only where the range actually ends, so several ranges each keep their own outline
fillHandle / fillPreviewThe square at the trailing corner of a range that starts a fill, and the cells the fill would write if released now
cutSource / moveTargetThe range a cut lifted from, and the cells it would land on
findMatch / findMatchCurrentA cell find matched, and the one the cursor is on
formatHighlightA cell an expression or duplicates rule matched. A colour scale and a data bar paint through cellDecoration.style instead, because the value they need cannot be named by a class
importStagedCell / importBadCellA cell holding a row that has been staged by the import wizard but not committed, and one whose value failed its column's schema

CSS Variables

VariableMeaning
--dg-row-hRow height. 2rem compact, 2.5rem standard, 3rem comfortable
--dg-header-hHeight of the leaf header row. Follows --dg-row-h until headerHeight sets it in pixels
--dg-group-header-hHeight of one header group tier. Follows --dg-row-h until groupHeaderHeight sets it in pixels
--dg-flash-tint / --dg-flash-msOn a cell flashCells() is marking: the colour of that mark and how long it lasts. The fade reads both
--dg-cell-pyVertical cell padding. 0.25rem, 0.5rem, 0.75rem
--dg-grid-templateThe grid-template-columns every row uses, written by the column model