Browse docs
Browse docs
A virtualized data table for large data sets (500 rows to
millions). Sibling component of <Table> — shares the same column
model, sort/search/filter/selection logic, cell renderer library,
RBAC integration, and visual identity. The difference is the render
strategy: DataGrid mounts only the window of visible rows in DOM,
making it performant even with 100k+ rows.
import { DataGrid } from '@dashforge/tw';
<DataGrid
rows={users}
cols={[
{ field: 'name', header: 'Name', sortable: true, searchable: true },
{ field: 'email', header: 'Email', searchable: true },
{ field: 'age', header: 'Age', sortable: true },
]}
getRowId={(row) => row.id}
rowHeight={48}
height="600px"
/>Both components ship the same visual identity (Stripe-style clean lines, hover row actions, sticky header, smart defaults, canonical theme patterns). They differ at the runtime characteristics level. Pick based on data size + features needed, not appearance.
| Dimension | <Table> | <DataGrid> |
|---|---|---|
| Render strategy | All rows in DOM | Windowed — only visible rows + 2 spacer <tr> |
| Row count target | ≤ ~500 (linear DOM scaling) | 500 → millions (virtualization) |
| Row height | Auto-fit content (cells can wrap) | Fixed (rowHeight prop required) |
| Variable height | ✓ | ✗ v1 (deferred to v1-bis) |
| Container height | Auto-fit (page scroll OK) | Must be bounded (height prop required) |
| Sticky header | ✓ | ✓ |
| Sticky LEFT column | ✗ | ✓ via cols[i].sticky: 'left' |
| Sticky RIGHT column | ✗ | ✓ via cols[i].sticky: 'right' (0.8.0-beta) |
| Per-column filter UI | ✗ | ✓ filterable: true (0.8.0-beta) |
| Column visibility dialog | ✗ | ✓ toolbar "Columns" button (0.8.0-beta) |
| Column resize via drag | ✗ | ✓ enableColumnResize (0.8.0-beta) |
| Column reorder via drag | ✗ | ✓ enableColumnReorder (0.8.0-beta) |
| Expandable rows | ✓ | ✗ v1 (v1-bis) |
| Server-side sort | ✗ | ✓ serverSideSort flag |
| Server-side filter | ✗ | ✓ serverSideFilter flag |
| Server-side search | ✗ | ✓ serverSideSearch flag |
| Server-side pagination | ✗ | ✓ serverSidePagination + totalCount |
| Internal pagination | ✗ (compose with <Pagination>) | ✓ opt-in via pagination prop |
selectAllScope | n/a (always all rows) | 'visible' | 'allLoaded' |
| Smart-default column types | ✓ | ✓ (same useColumnAutoDetect) |
| Sort (single + shift-multi) | ✓ | ✓ |
| Search debounced | ✓ | ✓ |
| Selection (none/single/multiple) | ✓ | ✓ |
| Bulk action footer | ✓ | ✓ |
| Row actions on hover | ✓ | ✓ |
| RBAC (table / column / action) | ✓ | ✓ |
| Cell renderer library | shared | shared |
| Variants | plain / lines / striped / bordered / card | plain / lines / striped / bordered |
card variant | ✓ | ✗ — border-separate is incompatible with the <table> virtualization spacer rows |
| Bundle cost | ~17 KB gz | +8.4 KB gz on top of Table |
| New runtime deps | none | none (homemade virtualization) |
<Table> when<DataGrid> when'visible' window vs 'allLoaded' full dataset)Both components share the same column model (TableColumn<T>),
same cell renderer library (RenderText, RenderTwoLine,
RenderChip, RenderButton, RowActionsMenu), and same i18n
surface (labels). Migrating from Table to DataGrid (when you
outgrow the row count) is mechanical:
- <Table
+ <DataGrid
rows={users}
cols={columns}
getRowId={(row) => row.id}
+ rowHeight={48}
+ height="600px"
/>The new required props are rowHeight + height. Everything else
keeps working. Expandable rows are the one feature you lose in
the swap — Table-specific until v1-bis.
<DataGrid
rows={users} // T[]
cols={columns} // TableColumn<T>[]
getRowId={(r) => r.id} // required for stable keys
rowHeight={48} // required for windowing math
height="600px" // required for container bounding
/>Both rowHeight and height are required for the virtualization
to compute the visible window. The height is a CSS length —
pass "600px", "60vh", or set via sx / parent layout.
| City | |||
|---|---|---|---|
| User 1 | [email protected] | 22 | Roma |
| User 2 | [email protected] | 23 | Milano |
| User 3 | [email protected] | 24 | Berlin |
| User 4 | [email protected] | 25 | London |
| User 5 | [email protected] | 26 | Paris |
| User 6 | [email protected] | 27 | Roma |
| User 7 | [email protected] | 28 | Milano |
| User 8 | [email protected] | 29 | Berlin |
| User 9 | [email protected] | 30 | London |
| User 10 | [email protected] | 31 | Paris |
| User 11 | [email protected] | 32 | Roma |
| User 12 | [email protected] | 33 | Milano |
| User 13 | [email protected] | 34 | Berlin |
import { DataGrid, type TableColumn } from '@dashforge/tw';
interface User {
id: string;
name: string;
email: string;
age: number;
city: string;
}
const CITIES = ['Roma', 'Milano', 'Berlin', 'London', 'Paris'];
// 200 rows — well above where Table starts to feel sluggish, but
// virtualization keeps only ~6 rows mounted in DOM at any time.
const USERS: User[] = Array.from({ length: 200 }).map((_, i) => ({
id: `u${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
age: 22 + (i % 50),
city: CITIES[i % CITIES.length] as string,
}));
const COLS: TableColumn<User>[] = [
{ field: 'name', header: 'Name', sortable: true },
{ field: 'email', header: 'Email' },
{ field: 'age', header: 'Age', sortable: true },
{ field: 'city', header: 'City' },
];
<DataGrid
rows={USERS}
cols={COLS}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
sx="w-full max-w-2xl"
/>Auto-detected from the first non-null value across visible rows:
| Inferred type | Alignment | tabular-nums |
|---|---|---|
number | right | yes |
boolean | center | no |
Date / ISO string | left | yes |
string | left | no |
Font family is the consumer's choice — tabular-nums is a
font-feature setting that preserves the consumer theme font-sans.
monospace: true is the explicit opt-in for font-mono.
DataGrid uses homemade virtualization (no @tanstack/react-virtual,
no react-window — zero new runtime deps). The hook
(useVirtualizer in _shared/data/) uses:
scroll event listener on the bounded containerrequestAnimationFrame debounce so scroll updates never exceed
one re-render per paint frameResizeObserver to re-virtualize when the container size changes
(window resize, sidebar toggle, etc.)<tr> approach: a <tr aria-hidden="true"> with
inline height renders ABOVE and BELOW the visible row window,
preserving the scrollbar size + <table> semanticsWindow math:
visibleStart = floor(scrollTop / rowHeight) - overscan
visibleEnd = ceil((scrollTop + viewportHeight) / rowHeight) + overscan
paddingTop = visibleStart * rowHeight
paddingBottom = (totalCount - 1 - visibleEnd) * rowHeightConfiguration:
| Prop | Default | Notes |
|---|---|---|
rowHeight | — (required) | Fixed pixel height per row. Variable height deferred to v1-bis. |
overscan | 5 | Extra rows rendered above + below the viewport. Reduces flash-of-blank on fast scroll. |
height | — (required) | Container CSS height. Must be bounded for virtualization. |
For a 10 000-row data set with rowHeight={48} and height="600px":
<tr> (13 visible + 5 overscan) + 2 spacer rowsrequestAnimationFrameEach pipeline step has an independent opt-in flag. With the flag
on, DataGrid emits the change event but does NOT run the operation
locally — the consumer is expected to provide the resulting rows
array.
<DataGrid
rows={pageOfRows} // The slice the server returned
cols={columns}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
// Sort emits but doesn't run locally — server returns pre-sorted rows
serverSideSort
sortModel={sortModel}
onSortChange={setSortModel} // → triggers a fetch
// Filter same shape
serverSideFilter
filterModel={filterModel}
onFilterChange={setFilterModel}
// Search same shape (debounced before emit, default 200ms)
serverSideSearch
searchQuery={query}
onSearchQueryChange={setQuery} // → triggers a fetch
// Virtual scroll height comes from totalCount, NOT rows.length
serverSidePagination
totalCount={1_000_000} // required when serverSidePagination
/>Mix and match. Each flag is independent. Use serverSideSort
alone if your filter happens locally but sort hits the backend.
Use serverSidePagination + totalCount to render a virtual
scrollbar for a dataset whose rows you fetch page-by-page from a
server.
Status | ||
|---|---|---|
| User 1 | [email protected] | pending |
| User 2 | [email protected] | active |
| User 3 | [email protected] | active |
| User 4 | [email protected] | pending |
| User 5 | [email protected] | active |
| User 6 | [email protected] | active |
| User 7 | [email protected] | pending |
| User 8 | [email protected] | active |
| User 9 | [email protected] | active |
| User 10 | [email protected] | pending |
| User 11 | [email protected] | active |
| User 12 | [email protected] | active |
| User 13 | [email protected] | pending |
import { useState } from 'react';
import {
DataGrid,
type TableColumn,
type TableSortModel,
type TableFilterModel,
} from '@dashforge/tw';
interface Row {
id: string;
name: string;
email: string;
status: 'active' | 'pending';
}
// Pretend this is a server-returned page slice. The consumer fetches
// it (via React Query / SWR / RTK Query) based on the emitted
// sort/filter/search/page events.
const PAGE: Row[] = Array.from({ length: 25 }).map((_, i) => ({
id: `r${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
status: i % 3 === 0 ? 'pending' : 'active',
}));
const COLS: TableColumn<Row>[] = [
{ field: 'name', header: 'Name', sortable: true, searchable: true },
{ field: 'email', header: 'Email', searchable: true },
{ field: 'status', header: 'Status', filterable: true },
];
function MyGrid() {
const [sortModel, setSortModel] = useState<TableSortModel>([]);
const [filterModel, setFilterModel] = useState<TableFilterModel>([]);
const [query, setQuery] = useState('');
return (
<DataGrid
rows={PAGE}
cols={COLS}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
enableSearch
serverSideSearch
searchQuery={query}
onSearchQueryChange={setQuery}
serverSideSort
sortModel={sortModel}
onSortChange={setSortModel}
serverSideFilter
filterModel={filterModel}
onFilterChange={setFilterModel}
serverSidePagination
totalCount={120}
sx="w-full max-w-2xl"
/>
);
}CSS-only — uses position: sticky; left: 0 (or right: 0) with a
z-index ladder that handles the corner intersection with the sticky
header:
z-index ladder:
thead (z-10)
< stickyLeftCell / stickyRightCell in body (z-[1])
< stickyLeftHeaderCell / stickyRightHeaderCell (z-20) ← corners highestApply via the column definition:
const cols: TableColumn<User>[] = [
{
field: 'name',
header: 'Name',
sticky: 'left', // pinned during horizontal scroll
width: 220, // fixed width is recommended for sticky
sortable: true,
},
// …middle columns scroll horizontally underneath
{
field: 'actions',
header: 'Actions',
sticky: 'right', // pinned on the trailing edge (0.8.0-beta)
width: 110,
cellRenderer: ({ row }) => <RowActionsMenu row={row} actions={…} />,
},
];Multiple sticky: 'left' columns stack left-to-right; multiple
sticky: 'right' columns stack right-to-left. Right-sticky cells get a
border-l automatically so they read as a separate "pinned" region
against the scrollable middle.
| City | Country | Actions | ||||
|---|---|---|---|---|---|---|
| User 1 | [email protected] | Roma | IT | $30,000 | 2024-01-15 | |
| User 2 | [email protected] | Milano | IT | $31,500 | 2024-02-15 | |
| User 3 | [email protected] | Berlin | DE | $33,000 | 2024-03-15 | |
| User 4 | [email protected] | London | GB | $34,500 | 2024-04-15 | |
| User 5 | [email protected] | Roma | IT | $36,000 | 2024-05-15 | |
| User 6 | [email protected] | Milano | IT | $37,500 | 2024-06-15 | |
| User 7 | [email protected] | Berlin | DE | $39,000 | 2024-07-15 | |
| User 8 | [email protected] | London | GB | $40,500 | 2024-08-15 | |
| User 9 | [email protected] | Roma | IT | $42,000 | 2024-09-15 | |
| User 10 | [email protected] | Milano | IT | $43,500 | 2024-10-15 | |
| User 11 | [email protected] | Berlin | DE | $45,000 | 2024-11-15 | |
| User 12 | [email protected] | London | GB | $46,500 | 2024-12-15 | |
| User 13 | [email protected] | Roma | IT | $48,000 | 2024-01-15 |
import { DataGrid, RowActionsMenu, type TableColumn } from '@dashforge/tw';
interface Row {
id: string;
name: string;
email: string;
city: string;
country: string;
salary: number;
joined: string;
}
const ROWS: Row[] = Array.from({ length: 100 }).map((_, i) => ({
id: `r${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
city: ['Roma', 'Milano', 'Berlin', 'London'][i % 4] as string,
country: ['IT', 'IT', 'DE', 'GB'][i % 4] as string,
salary: 30_000 + (i % 60) * 1_500,
joined: `2024-${String((i % 12) + 1).padStart(2, '0')}-15`,
}));
const COLS: TableColumn<Row>[] = [
{ field: 'name', header: 'Name', sticky: 'left', width: 180, sortable: true },
{ field: 'email', header: 'Email', width: 220 },
{ field: 'city', header: 'City', width: 140 },
{ field: 'country', header: 'Country', width: 100 },
{ field: 'salary', header: 'Salary (USD)', width: 140, sortable: true,
cellRenderer: ({ value }) => `${(value as number).toLocaleString('en-US')}` },
{ field: 'joined', header: 'Joined', width: 130, sortable: true },
];
<DataGrid
rows={ROWS}
cols={[
...COLS,
{
field: 'id',
header: 'Actions',
sticky: 'right',
width: 110,
resizable: false,
reorderable: false,
hideable: false,
cellRenderer: ({ row }) => (
<RowActionsMenu
row={row}
actions={[
{ label: 'Edit', onClick: (r) => console.log('edit', r.id) },
{ label: 'Delete', onClick: (r) => console.log('delete', r.id), color: 'danger' },
]}
/>
),
},
]}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
sx="w-full max-w-xl"
/>Opt in per column with filterable: true (0.8.0-beta). A filter icon
appears in the column header; clicking it opens a <Popover> with
the right input for the column type, auto-detected via
useColumnAutoDetect:
| Inferred type | Filter UI | Operator |
|---|---|---|
number | Min / Max inputs | between |
Date / ISO string | From / To date inputs | between |
boolean | All / True / False radios | equals |
string (default) | single text input | contains (case-insensitive) |
const cols: TableColumn<Order>[] = [
{ field: 'customer', header: 'Customer', filterable: true },
// number → range filter
{ field: 'total', header: 'Total', filterable: true },
// boolean → radios
{ field: 'paid', header: 'Paid', filterable: true },
// ISO date string → date range
{ field: 'date', header: 'Date', filterable: true },
];
<DataGrid
rows={orders}
cols={cols}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
// Optionally lift the filter model for persistence / server-side mode:
// filterModel={filterModel}
// onFilterChange={setFilterModel}
/>Override the autodetect with cols[i].filterType when the heuristic
picks the wrong one — e.g. an ID column typed as number but you want
a text-contains UI:
{ field: 'userId', header: 'User ID', filterable: true, filterType: 'text' }Operator model: every filter commits to the same filterModel
shape — { field, op, value }[] with op: 'contains' | 'equals' | 'between'. The between value is [min, max] where each end can be
null for an open range. The filter icon highlights primary-700
(aria-pressed=true) when an active filter applies to that column.
i18n: all filter labels go through the labels prop —
filterColumn / filterApply / filterClear / filterMin /
filterMax / filterFrom / filterTo / filterAll / filterTrue /
filterFalse.
Paid | |||
|---|---|---|---|
| Acme Corp | $100 | ✗ | 2024-01-01 |
| Globex | $137 | ✓ | 2024-02-02 |
| Initech | $174 | ✓ | 2024-03-03 |
| Umbrella | $211 | ✗ | 2024-04-04 |
| Soylent | $248 | ✓ | 2024-05-05 |
| Acme Corp | $285 | ✓ | 2024-06-06 |
| Globex | $322 | ✗ | 2024-07-07 |
| Initech | $359 | ✓ | 2024-08-08 |
| Umbrella | $396 | ✓ | 2024-09-09 |
| Soylent | $433 | ✗ | 2024-10-10 |
| Acme Corp | $470 | ✓ | 2024-11-11 |
| Globex | $507 | ✓ | 2024-12-12 |
| Initech | $544 | ✗ | 2024-01-13 |
import { DataGrid, type TableColumn } from '@dashforge/tw';
interface Order {
id: string;
customer: string;
total: number;
paid: boolean;
date: string;
}
const ORDERS: Order[] = Array.from({ length: 80 }).map((_, i) => ({
id: `o${i}`,
customer: ['Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Soylent'][i % 5] as string,
total: 100 + (i % 50) * 37,
paid: i % 3 !== 0,
date: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
}));
// Each `filterable: true` column gets a filter icon in its header.
// Filter UI auto-adapts: number → range, boolean → radios,
// date string → date range, anything else → text contains.
const COLS: TableColumn<Order>[] = [
{ field: 'customer', header: 'Customer', sortable: true, filterable: true },
{ field: 'total', header: 'Total', sortable: true, filterable: true,
cellRenderer: ({ value }) => `${(value as number).toLocaleString('en-US')}` },
{ field: 'paid', header: 'Paid', filterable: true },
{ field: 'date', header: 'Date', sortable: true, filterable: true },
];
<DataGrid
rows={ORDERS}
cols={COLS}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
sx="w-full max-w-2xl"
/>Three independent opt-in flags, all defaulting true in 0.8.0-beta:
enableColumnVisibility — adds a "Columns" button to the
toolbar that opens a <Dialog> with one checkbox per column.enableColumnResize — hover the right edge of any <th>,
cursor becomes col-resize, drag commits the new width.enableColumnReorder — drag any header onto another to
reposition; a 2px primary-500 indicator marks the drop side
(LEFT half = insert before, RIGHT half = insert after).Per-column opt-outs map to new TableColumn<T> axes:
const cols: TableColumn<User>[] = [
// hideable: false → not offered in the visibility dialog
// (structurally required, e.g. an ID column)
{ field: 'name', header: 'Name', hideable: false },
// defaultHidden: true → starts hidden, user re-shows via dialog
{ field: 'joined', header: 'Joined', defaultHidden: true },
// resize bounds — minWidth/maxWidth clamp the drag
{ field: 'email', header: 'Email', minWidth: 160, maxWidth: 320 },
// structurally pinned — no resize, no reorder, no hide
{ field: 'actions', header: '', sticky: 'right',
resizable: false, reorderable: false, hideable: false },
];All three states are controllable for persistence (LocalStorage, server-side prefs, URL):
<DataGrid
rows={rows}
cols={cols}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
hiddenColumns={hidden} onHiddenColumnsChange={setHidden}
columnWidths={widths} onColumnWidthsChange={setWidths}
columnOrder={order} onColumnOrderChange={setOrder}
/>Built on the existing <Dialog> (visibility), native pointer events
(resize), and native HTML5 drag-and-drop (reorder) — zero new
runtime deps.
| Role | Team | ||
|---|---|---|---|
| User 1 | [email protected] | admin | Platform |
| User 2 | [email protected] | editor | Growth |
| User 3 | [email protected] | viewer | Ops |
| User 4 | [email protected] | admin | Design |
| User 5 | [email protected] | editor | Platform |
| User 6 | [email protected] | viewer | Growth |
| User 7 | [email protected] | admin | Ops |
| User 8 | [email protected] | editor | Design |
| User 9 | [email protected] | viewer | Platform |
| User 10 | [email protected] | admin | Growth |
| User 11 | [email protected] | editor | Ops |
| User 12 | [email protected] | viewer | Design |
| User 13 | [email protected] | admin | Platform |
import { DataGrid, type TableColumn } from '@dashforge/tw';
interface Row {
id: string;
name: string;
email: string;
role: string;
team: string;
joined: string;
}
const ROWS: Row[] = Array.from({ length: 60 }).map((_, i) => ({
id: `r${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
role: ['admin', 'editor', 'viewer'][i % 3] as string,
team: ['Platform', 'Growth', 'Ops', 'Design'][i % 4] as string,
joined: `2024-${String((i % 12) + 1).padStart(2, '0')}-15`,
}));
// Three user-driven controls — all default-on:
// 1. The toolbar "Columns" button opens the visibility dialog.
// 2. Hover the right edge of a <th> and drag to resize.
// 3. Drag any header onto another to reorder.
const COLS: TableColumn<Row>[] = [
{ field: 'name', header: 'Name', width: 180, hideable: false, sortable: true },
{ field: 'email', header: 'Email', width: 220, minWidth: 160, maxWidth: 320 },
{ field: 'role', header: 'Role', width: 110 },
{ field: 'team', header: 'Team', width: 130 },
{ field: 'joined', header: 'Joined', width: 130, defaultHidden: true, sortable: true },
];
<DataGrid
rows={ROWS}
cols={COLS}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
sx="w-full max-w-2xl"
/>Selecting "all" in a 10 000-row data grid has two reasonable
semantics. The selectAllScope prop lets you pick:
| Scope | Header checkbox toggles… |
|---|---|
'allLoaded' (default) | All rows in the rows prop (i.e. the entire client-side dataset, post-search + post-filter) |
'visible' | Only the rows currently in the viewport window (~13 with rowHeight=48 + height=600px) |
<DataGrid
rows={users}
cols={cols}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
rowSelection="multiple"
selectAllScope="visible" // narrow scope — useful when total >> window
selectedRowIds={selected}
onSelectionChange={setSelected}
/>For "select all in the server-side dataset" (e.g. select all 1M rows that match a filter), the consumer wires it themselves:
async function selectAllOnServer() {
const ids = await fetch('/api/users/ids?filter=' + ...).then((r) => r.json());
setSelected(ids);
}DataGrid + virtualization can scroll a million rows smoothly — but
some UX patterns still want explicit "Page X of Y" navigation. Opt
in via the pagination prop:
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
<DataGrid
rows={users}
cols={cols}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
pagination={{
page,
pageSize,
onPageChange: setPage,
onPageSizeChange: setPageSize,
pageSizeOptions: [20, 50, 100, 200],
}}
/>The DataGrid renders a <Pagination> component (the Sprint 4
companion) below the virtualized scroll. The two layers — pagination
boundaries + virtualization windowing — compose naturally: each page
is virtualized internally if it's large enough.
| User 1 | [email protected] | pending |
| User 2 | [email protected] | active |
| User 3 | [email protected] | active |
| User 4 | [email protected] | pending |
| User 5 | [email protected] | active |
| User 6 | [email protected] | active |
| User 7 | [email protected] | pending |
| User 8 | [email protected] | active |
| User 9 | [email protected] | active |
| User 10 | [email protected] | pending |
import { useState } from 'react';
import { DataGrid, type TableColumn } from '@dashforge/tw';
interface Row {
id: string;
name: string;
email: string;
status: string;
}
const TOTAL = 200;
const ALL: Row[] = Array.from({ length: TOTAL }).map((_, i) => ({
id: `r${i}`,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
status: i % 3 === 0 ? 'pending' : 'active',
}));
const COLS: TableColumn<Row>[] = [
{ field: 'name', header: 'Name', sortable: true },
{ field: 'email', header: 'Email' },
{ field: 'status', header: 'Status', sortable: true },
];
function MyGrid() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const start = (page - 1) * pageSize;
const slice = ALL.slice(start, start + pageSize);
return (
<DataGrid
rows={slice}
cols={COLS}
getRowId={(row) => row.id}
rowHeight={40}
height="280px"
totalCount={ALL.length}
pagination={{
page,
pageSize,
onPageChange: setPage,
onPageSizeChange: setPageSize,
pageSizeOptions: [10, 25, 50],
}}
sx="w-full max-w-2xl"
/>
);
}DataGrid follows the same sx + slotProps dual-override pattern
as Table and every other @dashforge/tw component.
sx — outer wrapper<DataGrid sx="border-2 border-dashed" {...props} />slotPropsAvailable slots (mirror of Table's set, plus DataGrid-specific):
| Slot | Element |
|---|---|
root | outer <div> |
toolbar | search input wrapper above |
search | search input |
scroll | bounded scroll container |
table | <table> element |
thead / tbody | their respective elements |
headerRow / headerCell | <tr> / <th> in header |
row / cell | <tr> / <td> in body |
selectionCell | reserved checkbox cell |
rowActionsCell | reserved row-actions cell |
emptyState / loadingOverlay | placeholder elements |
bulkActionFooter | sticky-bottom action bar |
pagination | wrapper around the optional internal <Pagination> |
Plus the DataGrid-only stickyLeftCell and stickyLeftHeaderCell
internal classes (not exposed via slotProps in v1 — apply via
cols[i].sticky: 'left' instead).
Same imports as Table — they work in both:
import {
RenderText,
RenderTwoLine,
RenderChip,
RenderButton,
RowActionsMenu,
} from '@dashforge/tw';RowActionsMenu uses the Sprint 3 <Popover> component + per-action
RBAC.
<table> element preserved despite virtualization —
spacer rows use aria-hidden="true" so screen readers see
continuous structure.<th scope="col"> on every header.aria-sort="ascending" | "descending" | "none" on every sortable
header.aria-selected on rows when selection is active.labels prop
(same shape as Table — TableLabels).Pass t('...') directly to column headers; configure internal
default strings via labels:
import { useTranslation } from 'react-i18next';
function UsersGrid() {
const { t } = useTranslation();
return (
<DataGrid
rows={users}
cols={[
{ field: 'name', header: t('users.fields.name'), sortable: true, searchable: true },
// …
]}
getRowId={(r) => r.id}
rowHeight={48}
height="600px"
labels={{
searchPlaceholder: t('grid.search.placeholder'),
selectedCount: t('grid.selection.count'), // {count} placeholder
ariaSelectRow: t('grid.a11y.selectRow'),
ariaSelectAllRows: t('grid.a11y.selectAllRows'),
ariaSortAscending: t('grid.a11y.sortAsc'),
ariaSortDescending: t('grid.a11y.sortDesc'),
}}
/>
);
}| Prop | Type | Default | Notes |
|---|---|---|---|
rows | T[] | — (required) | Source data |
cols | TableColumn<T>[] | — (required) | Same shape as Table |
getRowId | (row, i) => string | — (required) | Stable row key |
rowHeight | number | — (required) | Fixed row height for windowing math |
height | string | — (required for virt to work) | Container CSS height |
overscan | number | 5 | Extra rows above/below viewport |
totalCount | number | rows.length | Required when serverSidePagination |
sortModel / onSortChange / defaultSortModel | TableSortModel | [] | Controlled / default sort |
serverSideSort | boolean | false | Skip local sort, emit onSortChange only |
enableSearch | boolean | false | Render search input above grid |
searchQuery / onSearchQueryChange | string | — | Controlled search |
searchPlaceholder | string | 'Search...' | Placeholder text |
searchDebounceMs | number | 200 | Debounce window |
serverSideSearch | boolean | false | Skip local search filter, emit only |
filterModel / onFilterChange | TableFilterModel | [] | Controlled filter (in-header UI shipped in 0.8.0-beta — set cols[i].filterable: true to enable). Operators: 'contains' | 'equals' | 'between'. |
serverSideFilter | boolean | false | Skip local filter, emit only |
hiddenColumns / onHiddenColumnsChange | string[] | seeded from col.defaultHidden | Controlled column visibility (0.8.0-beta) |
enableColumnVisibility | boolean | true | Show the "Columns" toolbar button (0.8.0-beta) |
columnWidths / onColumnWidthsChange | Record<string, number> | seeded from col.width | Controlled resized widths (0.8.0-beta) |
enableColumnResize | boolean | true | Allow user drag on right edge of <th> (0.8.0-beta) |
columnOrder / onColumnOrderChange | string[] | cols order | Controlled column order (0.8.0-beta) |
enableColumnReorder | boolean | true | Allow user drag-and-drop on headers (0.8.0-beta) |
rowSelection | 'none' | 'single' | 'multiple' | 'none' | Selection mode |
selectedRowIds / onSelectionChange | string[] | — | Controlled selection |
bulkActions | (rows: T[]) => ReactNode | — | Sticky-bottom action bar |
selectAllScope | 'visible' | 'allLoaded' | 'allLoaded' | Header checkbox scope |
pagination | DataGridPaginationConfig | — | Optional internal <Pagination> |
serverSidePagination | boolean | false | Use totalCount for virt math |
rowActions | (row: T) => ReactNode | — | Per-row actions slot |
loading | boolean | false | Render skeleton rows |
loadingRowCount | number | 8 | Skeleton row count |
emptyState | ReactNode | 'No data' (from labels) | Custom empty placeholder |
stickyHeader | boolean | true | Pin header during scroll |
caption | ReactNode | — | Optional table caption |
showCaption | boolean | false | If false, caption is sr-only |
access | AccessRequirement | — | Grid-level RBAC |
labels | TableLabels | English defaults | i18n strings |
variant | 'plain' | 'lines' | 'striped' | 'bordered' | 'lines' | Visual style (no card — incompatible with virt) |
size | 'sm' | 'md' | 'lg' | 'md' | Text size |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Row height modifier |
sx | string | — | Root override |
slotProps | DataGridSlotProps | — | Per-slot overrides |
| Sprint | Status | Feature |
|---|---|---|
| 4.2 | ✓ shipped 0.7.0-beta | Core DataGrid — virtualization + sticky left col + server-side mode + selectAllScope + internal pagination |
| 4.2-bis | ✓ shipped 0.8.0-beta | Per-column filter UI chips, column resize, column reorder, column visibility dialog, right-sticky |
| 5+ | planned | Variable row height (measurement cache), inline cell editing |
| post-1.0 | exploratory | Tree data / row grouping, aggregation, export, headless escape-hatch hook |
sx vs slotProps decision tree