Features
Everything on this page is in the free Community package.
Data
An array is the simple case:
<DataGrid dataSource={rows} rowKey="id" />
For data that lives on a server, pass a DataSource — an object with a
load(options) method. The grid hands it skip, take, sort, filter,
searchText and (when grouping remotely) group/groupKeys, and expects
{ rows, totalCount } back. createRestDataSource is that object for an HTTP
endpoint:
import { createRestDataSource } from '@kanunilabs/datagrid-react';
const source = createRestDataSource<Order>({ url: '/api/orders' });
<DataGrid dataSource={source} rowKey="id" remote={{ enabled: true }} />
It handles three things that are easy to get wrong by hand. Every load
aborts the one before it, so typing in the search box cannot leave the grid
showing the results of an earlier query. sort and filter are JSON-encoded
rather than stringified, which is what URLSearchParams would otherwise turn
into [object Object]. And a non-2xx response throws, because fetch
resolves on a 500 and the grid would otherwise try to render an error page as
rows.
Adapt it to an existing API without giving up any of that:
createRestDataSource<Order>({
url: '/odata/Orders',
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
buildQuery: (o) => ({ $skip: String(o.skip ?? 0), $top: String(o.take ?? 50) }),
mapResponse: (body) => ({ rows: body.value, totalCount: body['@odata.count'] }),
byKeyUrl: (key) => `/odata/Orders(${key})`,
});
headers is re-read per request, so a token refreshed mid-session reaches the
next call. byKeyUrl is optional — without it the grid does not offer byKey
at all rather than guessing a REST convention that may not be yours.
Columns
const columns: ReactGridColumnDef<Sale>[] = [
{ field: 'id', headerName: 'ID', dataType: 'number', width: 80, pin: 'left' },
{ field: 'product', headerName: 'Product', width: 220 },
{ field: 'revenue', headerName: 'Revenue', dataType: 'number', width: 140,
valueFormatter: (v) => `$${Number(v).toLocaleString()}` },
{ field: 'status', headerName: 'Status', width: 120, pin: 'right',
renderCell: ({ value }) => <strong>{String(value)}</strong> },
];
dataType (string · number · date · boolean · lookup) drives sorting,
filtering and alignment. valueFormatter is the cheap path — it returns a
string and is what export and clipboard copy use. renderCell is the expensive
path: arbitrary React per cell.
Pinned columns (pin: 'left' | 'right') are sticky cells inside the single
scroll container. Because there is no second table, the header and body cannot
fall out of alignment.
Other per-column switches: sortable, filterable, groupable, visible,
minWidth/maxWidth, align, and calculateSortValue to sort by a derived key.
Multi-level headers (bands)
band puts a caption row above a group of columns. One string is one level; an
array is several:
{ field: 'units', headerName: 'Units', band: ['Sales', 'Volume'] },
{ field: 'unitPrice', headerName: 'Unit price', band: ['Sales', 'Money'] },
{ field: 'revenue', headerName: 'Revenue', band: ['Sales', 'Money'] },
A band is a path on the column, not a nested column tree. The list stays flat, so widths, order, pinning, visibility, saved views, the column chooser and virtualization all keep working exactly as they do without bands.
Adjacent columns whose paths agree merge into one band cell. Two consequences worth knowing:
- A band splits when a column that is not in it sits between its members — a cell spanning a column that does not belong to it would be a lie. Reorder the columns together and the cells merge.
- Same-named bands under different parents stay separate:
Q1 › TotalandQ2 › Totalare two Totals, never one spanning both quarters.
Bands never cross the seam into a pinned block, and a column with no band at a given level simply has its own header reach up through it.
Columns from the data
With an array data source and no columns, one column per field is derived
from the rows:
<DataGrid dataSource={rows} rowKey="id" />
Field names are humanised (unitPrice → "Unit price", SKU stays "SKU",
customerId → "Customer ID") and data types are inferred by sampling — so
sorting a number column is numeric, not lexical. The sample is capped (50 rows
by default) rather than scanning the whole set, a column whose first values are
all null keeps looking, and a column that is null all the way down stays
untyped rather than being guessed at.
Tune it with autoColumns:
<DataGrid
dataSource={rows}
rowKey="id"
autoColumns={{
sampleSize: 200,
exclude: ['internalRef', 'rawPayload'],
defaults: { width: 140 },
}}
/>
A declared columns list always wins — this only fills in what you did not
write. A remote DataSource must declare its columns: there is nothing to
sample before the first fetch, and inventing them after it would reshape the
grid under the user.
Row drag & drop
rowDrag puts a grip at the start of each row on hover. Drag it to reorder:
<DataGrid dataSource={rows} rowKey="id" rowDrag={{ enabled: true }} />
Who owns the order is the decision to make. With no onReorder, the grid
moves its own array — fine when you hand it the data once. With a handler, you
own it and the grid changes nothing:
rowDrag={{
enabled: true,
onReorder: ({ dragKey, targetKey, position }) =>
api.move(dragKey, targetKey, position), // and re-render from your state
}}
If you keep the rows in component state, use the handler. Otherwise the next time you set that state you hand the grid a fresh array in the original order and every reorder silently disappears.
Reordering the grid's own array only works on an unsorted view: with a sort
active the sort decides the order, so the drop would be invisible. Rather than
move a row that visibly snaps back, the grid refuses and says why under
debugMode. To reorder a sorted grid, sort by an order field and rewrite that
field from onReorder — then the move is what the sort sees.
Dropping in the empty space below the last row sends it to the end.
Between two grids — give both the same group:
<DataGrid gridId="backlog" rowDrag={{ enabled: true, group: 'tasks',
onAdd: ({ row }) => setBacklog((l) => [...l, row]) }} />
<DataGrid gridId="sprint" rowDrag={{ enabled: true, group: 'tasks',
onAdd: ({ row, rowKey }) => { setSprint((l) => [...l, row]); removeFromBacklog(rowKey); } }} />
onAdd fires on the grid that receives the row, with the row itself — no
round trip needed. Removing it from the source is the app's call, because only
the app knows whether a cross-grid drag is a move, a copy, or an assignment.
Two grids without a shared group ignore each other, which is what you want
when a page has several unrelated grids.
Merged cells
spanRows draws one box over consecutive rows that hold the same value:
const columns = [
{ field: 'region', spanRows: true },
{ field: 'product' },
];
It is computed over the current view, which is the point — sorting a column
into A A A B B is what makes the merge meaningful, and the same rows unsorted
merge nothing. Sort, filter or edit and the blocks recompute.
Three rules that a plain "merge equal values" gets wrong:
- Blanks never merge. Twelve empty cells are twelve rows that happen to have nothing, not one twelve-row empty cell.
- A group banner interrupts a run. Rows either side of one are not adjacent to a reader, and the block would be drawn straight through it.
- A run of one is an ordinary cell, not a one-row block.
Pass a function instead of true for values that need their own equality — a
column of objects, or a case-insensitive code:
{ field: 'code', spanRows: (a, b) => String(a.value).toLowerCase() === String(b.value).toLowerCase() }
Only the drawing is merged. Selection, editing, the clipboard and export all still see the rows your data has — a block over five rows is five cells, which is what makes it safe to turn on over data you edit.
A block longer than the screen is clipped to what is rendered, so the value stays visible while you scroll through it rather than sitting at a top you scrolled past. Sorting a million rows by a five-value column is a legitimate thing to do; drawn at full height that block would be a 38-million-pixel element.
Cell notes
Prose a person attached to a cell — Excel's comments. Not a tooltip: a tooltip describes what the cell already says, a note is something someone added that is not in the data, so it carries a corner marker you can see without hovering.
const [notes, setNotes] = useState(new Map<string, string>());
const key = (rowKey, columnId) => `${rowKey}:${columnId}`;
<DataGrid
dataSource={rows}
rowKey="id"
notes={{
get: ({ rowKey, columnId }) => notes.get(key(rowKey, columnId)) ?? null,
onChange: ({ rowKey, columnId, text }) =>
setNotes((prev) => {
const next = new Map(prev);
if (text === null) next.delete(key(rowKey, columnId));
else next.set(key(rowKey, columnId), text);
return next;
}),
}}
/>
Storage is yours. The grid renders notes, reveals them on hover and collects edits; it does not decide where they live, because a note that survives a reload has to go somewhere only your app knows about.
get runs for every rendered cell, so make it a lookup — a Map keyed by
rowKey:columnId is the shape that fits. Anything that scans turns a viewport
into quadratic work.
Giving onChange is what makes notes editable: the grid then adds Add /
Edit / Delete to the row context menu for the cell under the pointer.
Ctrl+Enter saves, Escape cancels, and saving an empty note deletes it — a marker
on a cell whose note says nothing lies about there being something to read. Pass
menu: false to place those actions yourself.
A note outranks the column's tooltip, its tooltipField and the clipped
text. Those describe the column or repeat what is on screen; neither should hide
something a person wrote.
Aligned grids
Two grids that have to read as one table — a scrolling body with a fixed totals strip under it, a master and a detail over the same columns. Give them the same name:
<DataGrid gridId="body" dataSource={rows} columns={cols} alignedGrids="sales" columnResize />
<DataGrid gridId="totals" dataSource={totals} columns={cols} alignedGrids="sales" />
Resize, move, hide or pin a column in one and the others follow; scroll one sideways and the others keep pace.
Sort, filter, selection and vertical scroll deliberately do not travel. Aligned grids share a shape, not a result — a totals strip that re-sorted itself because the grid above it did would be answering a different question. Columns a peer does not have are ignored, so a three-column strip aligns under a twelve-column grid.
Row numbers
rowNumbers adds a leading column numbering the rows:
<DataGrid dataSource={rows} rowKey="id" rowNumbers />
The number is the row's position in what is shown. It renumbers after a sort or a filter, because that is what people mean by "the third row" — they mean the third one on screen. If you need a number that follows a record around, that is a column of your data, not this.
Three things follow from that:
- Pagination continues the count. Page 2 of 50 starts at 51. A second page starting at 1 again tells you nothing about where you are.
- Group headers get no number. A banner is not a record, and numbering it would push every row below it one past the count in the footer.
- Pinned rows get no number. They sit outside the result rather than at a position in it — otherwise a pinned row and the first data row would both claim to be row 1.
Sorting
Click a header to cycle ascending → descending → unsorted. With
sorting={{ multi: true }}, Shift+click adds a second key and the
header shows its position.
Sorting is Intl.Collator-based for text, chronological for dates, and blanks
always sort last regardless of direction.
Filtering
Three independent mechanisms, combined with AND:
Filter row (filterRow) — one input per column. Numeric and date columns
accept operator prefixes:
>=100 <50 <>0 2024-01-01
Header filter popup (headerFilter) — an Excel-style checklist of the
column's distinct values, with a search box. The list is complete — every
distinct value, even when every row is unique: it's windowed (~20 DOM nodes at
any length), and on large columns it's prepared in background slices behind a
loader so the page never freezes. Fully keyboard-driven: arrows to move,
Space to toggle, Backspace to jump back into the search
box, Enter to apply. Open it from the keyboard with
Alt+↓ on a focused header.
Programmatic filters — a serializable AST:
controller.setFilter({
logic: 'and',
nodes: [
{ columnId: 'region', operator: '=', value: 'Marmara' },
{ columnId: 'revenue', operator: 'between', value: [1000, 5000] },
],
});
Operators: = <> > >= < <= contains notcontains startswith
endswith isnull isnotnull in between.
Free-text search scans every visible column:
controller.setSearchText('istanbul');
Reading a filter back
describeFilter turns the AST into a sentence, resolving column ids to their
header names and lookup codes to their labels — so a user who arrives at a grid
showing 12 of 100.000 rows can see why:
describeFilter(controller.getFilter(), controller.getColumnModel());
// "Revenue > 50000 and (Region is Marmara or Region is Aegean)"
EnterpriseDataGrid already shows this above the grid when filterBuilder is
on. Pass labels to translate the operator words, and maxListValues to
change when a long in list collapses to "5 values".
Grouping and summaries
<DataGrid grouping={{ groupBy: 'region', panel: true }}
summaries={[{ columnId: 'revenue', type: 'sum' }]} />
Single-level grouping produces collapsible group headers with member counts.
The group panel lets users drop a column to group by it (with a <select>
fallback so it works from the keyboard).
Footer summaries support sum avg min max count countDistinct, plus
type: 'custom' with a calculate function that receives the visible cells in
view order. Blanks are skipped rather than counted as zero, so [10, null, 20]
averages to 15 — spreadsheet semantics.
Above ~100k rows the summary pass streams in slices so the rows never wait for
the footer: contentReady fires first, the totals land moments later with a
summariesChanged event. Sorting re-runs a custom summary by default because
its cells arrive in view order; if your calculate doesn't care about order
(sums, ratios, averages), declare dependsOnOrder: false and re-sorting skips
it entirely.
Multi-level grouping and per-group aggregates are Enterprise features — pass a
groupBy array and groupSummaries: true to EnterpriseDataGrid. The
Community grid clamps groupBy to its first level and ignores groupSummaries
rather than half-honoring them.
Cell context menu
Right-click a cell (or press Shift+F10) and the grid
offers its own clipboard and export entries, below whatever rowMenu supplies:
| Entry | Tier | Notes |
|---|---|---|
| Copy | Community | The selected cells, or the selected rows when there is no range. Same code path as Ctrl+C. |
| Copy with headers | Community | One header line above the values. |
| Copy with group headers | Community | Band captions above the header line. Hidden on a grid with no bands, where it would be identical to the entry above. |
| Export ▸ | Community | CSV, Excel, Excel (selected rows), PDF — the same four the toolbar offers. "Selected rows" is disabled with an empty selection. |
| Cut · Paste | Enterprise | Write through the editing engine — one undo step, and validation applies, so a required field refuses to be emptied. Shown only on a cell the permissions allow writing. |
| Chart selected columns ▸ | Enterprise | Opens the chart panel over the selected columns, in the type you pick. |
// Every entry is on by default; this REMOVES rather than opts in.
<DataGrid cellMenu={{ copyWithGroupHeaders: false, export: ['csv', 'excel'] }} />
<DataGrid cellMenu={false} /> // no built-ins; `rowMenu` still shown
Excel and PDF need their optional peers. exceljs and jspdf +
jspdf-autotable are optional peerDependencies, so on a project that never
installed them those two entries throw when pressed. The grid does not hide
them for that: it shows the writer's message — which names the package and the
install command — in an alert at the menu's corner, and logs it. Narrow the
list with export: ['csv'] if you know the peers will never be there.
"Chart selected columns" charts columns, not a row range. The rows it plots
are the current view — a chart definition names a source (view / selection /
all) and has no way to say "these row indices", so the entry is named for what
it actually does rather than borrowing a term from a grid that works differently.
Chrome columns never reach the clipboard. The command column and the row-number column are real columns in the model (that is what makes width, order and pinning work for them), but they hold buttons, not data — copying a row emits the data columns only.
Selection and keyboard
<DataGrid selection={{ mode: 'multiple', checkboxColumn: true }} />
Click replaces, Ctrl/Cmd+click toggles, Shift+click extends a range. Selection is keyed by row identity, so it survives sorting, filtering and data updates.
The full keyboard model:
| Key | Action |
|---|---|
| ↑ ↓ ← → | Move focus |
| Page↑ Page↓ | Move a page |
| Home / End | Row start / end |
| Ctrl+Home / End | Grid start / end |
| Space | Toggle selection (or expand a group) |
| Shift+↑/↓ | Extend selection |
| Ctrl+A | Select all |
| Ctrl+C | Copy as TSV |
| Esc | Clear selection |
| Alt+↓ | Open the header filter (on a header) |
Accessibility
role="grid" with aria-rowindex / aria-colindex / aria-rowcount,
aria-selected on rows, and a polite live region announcing row counts, sort
changes and selection size.
Focus inside the grid is virtual (aria-activedescendant) because moving
real DOM focus would fight row virtualization. The header uses a roving
tabindex, so the whole header is one tab stop rather than one per column.
Export and import
const { blob, fileName } = await controller.exportToCsv();
downloadBlob(blob, fileName);
CSV follows RFC 4180 and is written with a UTF-8 BOM so Excel opens it with the
right encoding. Excel export (exportToExcel) needs the optional exceljs
peer and writes numbers and dates as real numbers and dates so they stay
computable.
Exports reflect what you see: formatted values, visible columns, current sort/filter/grouping — and the whole result, not just the current page. Both formats are produced from one model, so they cannot drift apart.
Excel: from a dump to a report
Four options turn the sheet into something you can hand to someone.
Per-cell rules. customizeCell runs for every cell and returns a style to
merge over whatever the writer chose — or nothing, to leave it alone:
await controller.exportToExcel({
styled: true,
customizeCell: ({ kind, columnId, value }) => {
if (kind !== 'data' || columnId !== 'revenue') return;
if (Number(value) < 0) return { fill: 'FFFEE2E2', font: { color: 'FFB91C1C' } };
},
});
kind is header · data · group · totals · band, so a rule can target
the totals row without touching the data. The hook is on the hot path — one
call per cell — so it is skipped entirely when you do not pass one.
Rows above and below the table, for a title, a date, a note:
headerBlock: [
{ cells: ['Quarterly revenue'], merge: true, style: { font: { bold: true, size: 15 } } },
{ cells: [`Generated ${today}`] },
{ cells: [] }, // a blank spacer row
],
footerBlock: [{ cells: ['Figures are unaudited.'] }],
merge spans the row across the table's width. The freeze line and the
auto-filter follow the real header row, so a title above the table does not
push either onto the wrong row.
A logo, anchored in sheet coordinates:
images: [{ data: base64Png, extension: 'png',
position: { col: 5, row: 0, width: 90, height: 30 } }],
More than one grid. A block with a sheetName gets its own sheet; without
one it is stacked below the previous block on the same sheet:
additionalGrids: [
{ model: summaryModel, title: 'By region', gap: 2 },
{ model: detailModel, sheetName: 'Detail' },
],
The auto-filter stays on the first grid's header — Excel allows one per sheet, and pointing it at a stacked block would filter rows the reader is not looking at.
A fileName that already ends in .xlsx is left alone rather than becoming
report.xlsx.xlsx.
Printing
Ctrl+P on a virtualized grid prints the twenty rows that happened to be on
screen, inside a box the browser has clipped to one viewport. printLayout
fixes that:
<DataGrid dataSource={rows} rowKey="id" printLayout />
On beforeprint the grid renders every row and stops clipping itself, so
the browser paginates real content; afterprint puts it back. Scroll position,
selection and filters all survive — only the rendering changed. Controls that
mean nothing on paper (toolbar, pager, resize handles, funnels) are hidden, rows
are kept from splitting across a page break, and background colours are asked
for explicitly so the header and striping survive Chrome's default.
It refuses above 5.000 rows. Print layout puts every row in the DOM; at a
hundred thousand that is a frozen tab with a print dialog open, which the user
cannot cancel out of. Past the cap the grid prints what it always did and says
so under debugMode. For a result that size use PDF export — it
streams. Move the line with printLayout={{ maxRows: 20000 }} if your rows are
narrow and you have measured it.
Your own layout has to let go too. A grid is usually mounted as a flex child filling a viewport-height shell, and the grid can only unclip itself and its own wrappers. While printing it marks the root element, so one rule releases yours:
@media print {
.kanuni-datagrid-printing .my-app-shell {
height: auto !important;
overflow: visible !important;
flex: none !important;
}
}
printLayout={{ always: true }} stays in that mode permanently, for rendering a
grid into a report page where there is no print dialog to react to.
exportToPdf needs the optional jspdf and jspdf-autotable peers — a grid
that never exports PDF should not pay for them, so they are loaded on first use
and a missing install is reported as an install hint rather than a
module-not-found stack.
const { blob, fileName } = await controller.exportToPdf();
downloadBlob(blob, fileName);
The API is deliberately the same as the Excel report's — customizeCell,
headerBlock / footerBlock, additionalGrids, images — so a report you
have already styled for Excel does not need a second vocabulary:
await controller.exportToPdf({
orientation: 'landscape', // default; 'portrait' also accepted
pageSize: 'a4', // 'a3' · 'letter' · 'legal'
pageNumbers: (page, total) => `Page ${page} of ${total}`,
headerBlock: [
{ text: 'Quarterly revenue', fontSize: 15, fontStyle: 'bold' },
{ text: `Generated ${today}`, fontSize: 9, color: [100, 116, 139] },
],
footerBlock: [{ text: 'Figures are unaudited.', fontStyle: 'italic' }],
customizeCell: ({ kind, columnId, value }) => {
if (kind !== 'data' || columnId !== 'revenue') return;
if (Number(value) < 0) return { fillColor: [254, 226, 226], textColor: [185, 28, 28] };
},
additionalGrids: [{ model: detailModel, title: 'Detail', pageBreak: true }],
});
Colours are RGB triples (0–255) rather than the ARGB strings Excel uses, because that is what a PDF actually stores.
Large tables are laid out in slices. autoTable lays a table out in one
synchronous call, so past ~400 rows the writer feeds it a slice at a time and
yields between them — onProgress ticks, signal can cancel, and the tab
stays alive instead of freezing until the file is done. Measured on the bench
at 4.000 rows × 15 columns:
sliceRows | Total | Longest freeze |
|---|---|---|
0 (one call) | 14,9 s | ~15 s |
| 1000 (default) | 12,8 s | 3,3 s |
| 500 | 13,0 s | 1,9 s |
Slicing costs nothing in total time — but the slices must not get too small:
every call redoes its own setup, and an early attempt that sliced one PAGE at a
time (≈45 rows) took minutes. Set sliceRows lower for shorter freezes,
or 0 to force the single call and the tightest possible document.
PDF is not a format for a million rows. The measurements above scale linearly: that 4.000-row file is already 18 MB, so a million rows is gigabytes and hours whatever the slicing. Use CSV or Excel at that size, or filter first.
Three behaviours worth knowing:
Column headers repeat on every page (repeatHeaders, default on). A table
that spills onto page three with no headings is unreadable on paper, where you
cannot scroll back up to check which column is which.
PDF carries the formatted text, where Excel writes raw numbers and dates. A PDF is a picture of the data, not a spreadsheet you go on computing with, so there is nothing to gain from a raw value that then has to be formatted for drawing anyway.
A footer that would not fit gets its own page. Text drawn past the page edge is simply invisible, so the note would silently vanish exactly when the report is long enough to need one.
If images data is not a real file of the declared format, the export fails
with a message naming which image and why. jsPDF decodes an image to read its
dimensions, so a malformed one cannot be embedded — unlike Excel, which stores
the bytes without looking at them and would produce a quietly broken file.
CSV import sniffs the delimiter (, ; tab |), infers column types, and
reports malformed rows instead of hiding them:
const { columns, rows, malformedRows } = importCsv(text);
Pagination and toolbar
<DataGrid pagination={{ enabled: true, pageSize: 100 }} toolbar />
Pagination and virtualization coexist — the pager slices the view and the window still virtualizes the page. Changing a filter resets to page 1.
The toolbar provides debounced search, a column chooser and export buttons. Your own controls go beside them rather than in a second bar above:
<DataGrid
toolbar
toolbarItemsBefore={<button onClick={refresh}>Refresh</button>}
toolbarItemsAfter={<button onClick={openReport}>Report…</button>}
/>
Saved views
const view = controller.getViewState(); // JSON-safe
controller.applyViewState(view);
Captures column widths, order, pinning and visibility, plus sort, filters, search, grouping and pagination.
Remembering the layout automatically
stateStoring does the same thing without you writing the wiring: it restores
once on mount and saves on every layout change, debounced.
<DataGrid stateStoring={{ enabled: true }} />
The key defaults to kanuni-datagrid-<gridId>, so two grids on a page never
share a layout. type chooses 'localStorage' (default), 'sessionStorage'
or 'custom'; custom hands you customLoad/customSave, either of which
may be async, for a layout kept on your server per user.
<DataGrid
stateStoring={{
enabled: true,
type: 'custom',
customLoad: () => fetch(`/api/grid-layout/${userId}`).then((r) => r.json()),
customSave: (state) =>
fetch(`/api/grid-layout/${userId}`, { method: 'PUT', body: JSON.stringify(state) }),
}}
/>
Three behaviours are deliberate. The layout is restored once, at mount — a
later restore would undo the sort the user just asked for. Writes are
debounced (saveDelay, default 500 ms) because dragging a column edge
fires a change per pixel, and a pending write is still flushed when the grid
unmounts. And a failed restore is never fatal: storage that is full,
disabled by privacy settings, or holding a snapshot from an older build leaves
the grid opening with its default layout instead of refusing to open.
Row template
renderRow replaces a data row's content — a card, a chart line, a summary
sentence — while the header, virtualization, selection and keyboard all keep
working:
<DataGrid
renderRow={({ row, selected }) => (
<div className={selected ? 'row card selected' : 'row card'}>
<strong>{row.product}</strong> — {row.units} × {row.unitPrice}
</div>
)}
/>
Group headers and detail panels keep their own rendering: a template that had to handle those as well would be a template of the whole grid.
What the template replaces are the cells — so while it is on, in-cell
editing, range selection and the fill handle have nothing to attach to. For
per-cell customization that keeps the column layout, use a column's
renderCell instead.
Density
Three presets set the row height, header height, type scale and cell padding together:
<DataGrid density="compact" /* | "normal" | "comfortable" */ />
| Row height | Header | Font | |
|---|---|---|---|
compact | 24 px | 28 px | 12 px |
normal (default) | 28 px | 32 px | 13 px |
comfortable | 36 px | 40 px | 14 px |
The row height is a virtualization value, not only a CSS variable, so switching
density re-measures the scroll extent — a 100.000-row grid goes from a
2.800.000 px canvas to 2.400.000 px on compact. An explicit rowHeight still
wins: density is a shorthand, not an override.
Row context menu
<DataGrid
rowMenu={[
{ id: 'copy', label: 'Copy SKU', shortcut: 'Ctrl+C', onSelect: (ctx) => copy(ctx.row.sku) },
{ separator: true },
{ id: 'more', label: 'More', items: [{ id: 'log', label: 'Log row', onSelect: log }] },
]}
/>
Pass a function instead of an array to build the items from the row that was clicked — returning an empty list suppresses the menu for that row:
rowMenu={(ctx) => (ctx.row.locked ? [] : itemsFor(ctx.row))}
The context carries row, node, rowIndex, the columnId under the pointer
and the selectedKeys at the moment the menu opened. Right-clicking a row that
is not selected selects it first, so an action driven by the selection operates
on what the user pointed at.
The menu is keyboard-operable: Shift+F10 or the ContextMenu key opens it on the focused row, arrow keys move (skipping disabled items), → and ← enter and leave a submenu, Enter activates and Esc closes. It is clamped to the viewport, so opening it on the last row does not push it off-screen.
Theming
Styles are plain CSS driven by custom properties, scoped under
.kanuni-datagrid-root:
.kanuni-datagrid-root {
--dg-primary: #7c3aed;
--dg-row-height: 32px;
--dg-border: #e5e7eb;
}
Pass dark for the packaged dark palette.
Charts
The chart MODEL is here in Community: useGridChartData() turns the grid's
current result into categories and aggregated series, ready for any chart
library. The panel and its renderers are Enterprise — see
Charts.
import { useGridChartData } from '@kanunilabs/datagrid-react';
const model = useGridChartData(controller, {
categoryColumnId: 'region',
series: [{ columnId: 'revenue', aggregate: 'sum' }],
});
Missing a feature? Tell us
This grid is shaped by the people using it. If something is missing, awkward or broken, we want to hear about it — send us your feedback.