Browse docs
Browse docs
A declarative-first data table for @dashforge/tw. Built from
market research (TanStack, MUI X, AG-Grid feature parity + UX
patterns from Stripe / Atlassian / Pencil & Paper UX research)
without inheriting bias from any single existing implementation.
import { Table } from '@dashforge/tw';
<Table
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}
/>| Name | Status | |
|---|---|---|
| Alice Anderson | [email protected] | active |
| Bob Brown | [email protected] | pending |
| Charlie Calwell | [email protected] | active |
| Diana Dempsey | [email protected] | active |
import { Table, type TableColumn } from '@dashforge/tw';
interface User {
id: string;
name: string;
email: string;
status: 'active' | 'pending';
}
const USERS: User[] = [
{ id: '1', name: 'Alice Anderson', email: '[email protected]', status: 'active' },
{ id: '2', name: 'Bob Brown', email: '[email protected]', status: 'pending' },
{ id: '3', name: 'Charlie Calwell', email: '[email protected]', status: 'active' },
{ id: '4', name: 'Diana Dempsey', email: '[email protected]', status: 'active' },
];
const COLS: TableColumn<User>[] = [
{ field: 'name', header: 'Name' },
{ field: 'email', header: 'Email' },
{ field: 'status', header: 'Status' },
];
<Table
rows={USERS}
cols={COLS}
getRowId={(row) => row.id}
sx="w-full max-w-xl"
/><TextField>, <Select>, etc.<DataGrid> (Sprint 4.2)
for 1k+ rows with virtualization.Table auto-detects the type of each column from the first non-null value across the visible rows and applies sensible alignment + digit-grid typography:
| Inferred type | Alignment | tabular-nums (digit grid) |
|---|---|---|
number | right | yes |
boolean | center | no |
Date / ISO string | left | yes |
string | left | no |
| anything else | left | no |
The most common bug in hand-rolled tables — $1,111.11 looking
visually smaller than $999.99 because of proportional-width
digits — is fixed by default thanks to tabular-nums.
The Table never changes the font family. Number / date columns
get font-variant-numeric: tabular-nums (digit grid alignment),
which works with whatever font the consumer set in their theme
(via tailwind.config.ts theme.extend.fontFamily.sans or
similar). The cell text inherits the parent's font, exactly like
every other component in @dashforge/tw.
If you genuinely want a monospace font on a specific column (e.g. a raw transaction hash, a SHA), opt in explicitly:
{ field: 'sha', header: 'SHA', monospace: true }The font-mono class then applies, and resolves to whatever the
consumer configured as the mono stack
(theme.extend.fontFamily.mono) — or Tailwind's default mono fallback if not
configured.
const cols = [
// Auto: left + no tabular-nums (string column)
{ field: 'name', header: 'Name' },
// Auto: right + tabular-nums (number column)
{ field: 'age', header: 'Age' },
// Auto: left + tabular-nums (date column). Font family unchanged.
{ field: 'createdAt', header: 'Created' },
// Auto: center (boolean)
{ field: 'active', header: 'Active' },
// Explicit override on alignment
{ field: 'name', header: 'Name', align: 'right' },
// Opt OUT of tabular-nums on a number column (rare — when the column
// uses bespoke formatting like prose).
{ field: 'estimate', header: 'Estimate', tabularNums: false },
// Opt IN to monospace font family (raw hex / IDs).
{ field: 'sha', header: 'SHA', monospace: true },
];Translations are first-class. Column headers accept plain strings —
just pass t('...') directly. Internal default strings (search
placeholder, a11y labels, selection counter, density toggle) are
configurable via the labels prop with English defaults — same
pattern as <Pagination>.
import { useTranslation } from 'react-i18next';
function UsersTable() {
const { t } = useTranslation();
return (
<Table
rows={users}
cols={[
{ field: 'name', header: t('users.fields.name'), sortable: true, searchable: true },
{ field: 'email', header: t('users.fields.email'), searchable: true },
{ field: 'role', header: t('users.fields.role'),
cellRenderer: ({ value }) => t(`users.roles.${value}`) },
]}
getRowId={(r) => r.id}
enableSearch
rowSelection="multiple"
bulkActions={(rows) => (
<Button>{t('actions.delete', { count: rows.length })}</Button>
)}
emptyState={<span>{t('users.empty')}</span>}
labels={{
searchPlaceholder: t('table.search.placeholder'),
noData: t('table.empty.fallback'),
selectedCount: t('table.selection.count', { defaultValue: '{count} selected' }),
clearSelection: t('table.selection.clear'),
ariaSelectRow: t('table.a11y.selectRow'),
ariaSelectAllRows: t('table.a11y.selectAllRows'),
ariaExpandRow: t('table.a11y.expandRow'),
ariaCollapseRow: t('table.a11y.collapseRow'),
ariaSortNone: t('table.a11y.sortNone'),
ariaSortAscending: t('table.a11y.sortAsc'),
ariaSortDescending: t('table.a11y.sortDesc'),
}}
/>
);
}{count} in the selectedCount label is replaced at render time with
the actual selection count.
Make a column sortable with sortable: true (default comparator
handles string / number / Date / boolean) or pass a custom
comparator (a, b) => number.
<Table
cols={[
{ field: 'name', header: 'Name', sortable: true },
{ field: 'age', header: 'Age', sortable: true },
// Custom: sort by name length
{ field: 'name', header: 'Name (length)',
sortable: (a, b) => a.name.length - b.name.length },
]}
rows={users}
/>Cycle: click a sortable header → asc · click again → desc ·
click a third time → cleared. Single-click on a different column
replaces the sort.
Multi-column sort: hold Shift while clicking. Each shift-click adds the column to the sort model (cycles its direction if already present, removes it on the third click).
Controlled: pass sortModel + onSortChange to lift state up.
Null handling: null / undefined values always sort LAST,
regardless of direction. This matches user expectations and avoids
the "null appears at the top on desc" surprise.
Set enableSearch and flag the columns to include via
searchable: true. Search is case-insensitive substring match
across the joined value of all searchable columns.
<Table
enableSearch
searchPlaceholder="Search users..."
searchDebounceMs={200}
cols={[
{ field: 'name', header: 'Name', searchable: true },
{ field: 'email', header: 'Email', searchable: true },
{ field: 'meta.city', header: 'City', searchable: true }, // nested keys supported
]}
rows={users}
getRowId={(r) => r.id}
/>Nested keys like meta.city resolve at runtime via the same
getNestedValue helper that powers cell rendering — the type-level
autocomplete from NestedKeyOf<T> matches the runtime behavior.
Debounce: default 200ms. Lower (0) to disable, raise to
reduce filter rate on very large datasets.
| Alice | [email protected] | 32 |
| Bob | [email protected] | 28 |
| Charlie | [email protected] | 45 |
| Diana | [email protected] | 24 |
| Edward | [email protected] | 51 |
import { Table, type TableColumn } from '@dashforge/tw';
interface User {
id: string;
name: string;
email: string;
age: number;
}
const USERS: User[] = [
{ id: '1', name: 'Alice', email: '[email protected]', age: 32 },
{ id: '2', name: 'Bob', email: '[email protected]', age: 28 },
{ id: '3', name: 'Charlie', email: '[email protected]', age: 45 },
{ id: '4', name: 'Diana', email: '[email protected]', age: 24 },
{ id: '5', name: 'Edward', email: '[email protected]', age: 51 },
];
const COLS: TableColumn<User>[] = [
{ field: 'name', header: 'Name', sortable: true, searchable: true },
{ field: 'email', header: 'Email', sortable: true, searchable: true },
{ field: 'age', header: 'Age', sortable: true },
];
<Table
rows={USERS}
cols={COLS}
getRowId={(row) => row.id}
enableSearch
searchPlaceholder="Search users…"
defaultSortModel={[{ field: 'name', direction: 'asc' }]}
sx="w-full max-w-xl"
/>Edge cases handled (vs naive implementations):
| Value | Searchable as |
|---|---|
null / undefined | empty string (skipped) |
string | as-is |
number / bigint | String(value) |
boolean | 'true' / 'false' |
Date | ISO 8601 |
Array | space-joined stringified elements |
| object | JSON.stringify |
<Table
rowSelection="multiple" // 'none' | 'single' | 'multiple'
selectedRowIds={selected} // controlled (optional)
onSelectionChange={setSelected}
bulkActions={(rows) => (
<>
<Button color="danger">Delete {rows.length}</Button>
<Button variant="outline">Export</Button>
</>
)}
rows={users}
cols={cols}
getRowId={(r) => r.id}
/>single: at most one row at a timemultiple: any subset selectablelabels.ariaSelectRow
/ labels.ariaSelectAllRows for i18n.| INV-001 | Acme Corp | $1,240 | |
| INV-002 | Globex Inc | $4,800 | |
| INV-003 | Initech | $320 | |
| INV-004 | Umbrella | $9,999 |
import { useState } from 'react';
import { Table, Button, Stack, type TableColumn } from '@dashforge/tw';
interface Invoice {
id: string;
number: string;
customer: string;
total: number;
}
const INVOICES: Invoice[] = [
{ id: '1', number: 'INV-001', customer: 'Acme Corp', total: 1240 },
{ id: '2', number: 'INV-002', customer: 'Globex Inc', total: 4800 },
{ id: '3', number: 'INV-003', customer: 'Initech', total: 320 },
{ id: '4', number: 'INV-004', customer: 'Umbrella', total: 9999 },
];
const COLS: TableColumn<Invoice>[] = [
{ field: 'number', header: 'Invoice', sortable: true },
{ field: 'customer', header: 'Customer', sortable: true },
{ field: 'total', header: 'Total', sortable: true,
cellRenderer: ({ value }) => `${(value as number).toLocaleString('en-US')}` },
];
function MyTable() {
const [selected, setSelected] = useState<string[]>([]);
return (
<Table
rows={INVOICES}
cols={COLS}
getRowId={(row) => row.id}
rowSelection="multiple"
selectedRowIds={selected}
onSelectionChange={setSelected}
bulkActions={(rows) => (
<Stack direction="row" gap={2}>
<Button color="danger" size="sm">Delete {rows.length}</Button>
<Button variant="outline" size="sm">Export</Button>
</Stack>
)}
sx="w-full max-w-xl"
/>
);
}const [expanded, setExpanded] = useState<string[]>([]);
<Table
rows={users}
cols={cols}
getRowId={(r) => r.id}
expandable={{
expandedRowIds: expanded,
onExpandChange: setExpanded,
render: (row) => (
<div>
<strong>{row.name}</strong>
<p>Joined: {row.joinedAt.toLocaleDateString()}</p>
<p>{row.bio}</p>
</div>
),
}}
/>The expand toggle appears as a chevron in a dedicated column.
A11Y: aria-expanded + aria-label (translatable via labels.ariaExpandRow
/ labels.ariaCollapseRow).
| ORD-001 | Acme Corp | |
| ORD-002 | Globex Inc | |
| ORD-003 | Initech |
import { Table, Stack, Typography, type TableColumn } from '@dashforge/tw';
interface Order {
id: string;
number: string;
customer: string;
items: { sku: string; qty: number; name: string }[];
}
const ORDERS: Order[] = [
{ id: '1', number: 'ORD-001', customer: 'Acme Corp',
items: [{ sku: 'SKU-A1', qty: 2, name: 'Widget' },
{ sku: 'SKU-B2', qty: 1, name: 'Gadget' }] },
{ id: '2', number: 'ORD-002', customer: 'Globex Inc',
items: [{ sku: 'SKU-C3', qty: 5, name: 'Gizmo' }] },
];
const COLS: TableColumn<Order>[] = [
{ field: 'number', header: 'Order', sortable: true },
{ field: 'customer', header: 'Customer', sortable: true },
];
<Table
rows={ORDERS}
cols={COLS}
getRowId={(row) => row.id}
expandable={{
render: (row) => (
<Stack gap={1} sx="py-2">
<Typography variant="caption" sx="text-neutral-500">
Line items
</Typography>
{row.items.map((it) => (
<Stack key={it.sku} direction="row" gap={3} sx="text-sm">
<span className="font-mono text-neutral-500">{it.sku}</span>
<span>{it.name}</span>
<span className="ml-auto">× {it.qty}</span>
</Stack>
))}
</Stack>
),
}}
sx="w-full max-w-xl"
/>Render a per-row actions slot revealed on row hover or focus-within (Stripe pattern — reduces visual density while keeping actions discoverable).
import { RowActionsMenu } from '@dashforge/tw';
<Table
rows={users}
cols={cols}
getRowId={(r) => r.id}
rowActions={(row) => (
<RowActionsMenu
row={row}
actions={[
{ label: 'Edit', onClick: (r) => editUser(r) },
{ label: 'Duplicate', onClick: (r) => duplicateUser(r) },
{
label: 'Delete',
onClick: (r) => deleteUser(r),
color: 'danger',
access: { resource: 'user', action: 'delete', onUnauthorized: 'hide' },
},
]}
/>
)}
/>RowActionsMenu is a 3-dot trigger that opens a Popover (the
Sprint 3 <Popover> component). Per-action RBAC honored — hide
removes the action entirely; disable / readonly render it
disabled.
Table supports three levels of RBAC:
| Level | Where | Effect |
|---|---|---|
| Table-level | <Table access={…}> | hide → renders null. disable greys + disables every interactive element. |
| Per-column | cols[i].access | hide removes the column from header AND every row cell entirely. disable / readonly are forwarded to cellRenderer via ctx.access. |
| Per-action | <RowActionsMenu actions[i].access> | hide filters out the action; disable renders it disabled. |
<Table
rows={users}
cols={[
{ field: 'name', header: 'Name' },
{
field: 'salary',
header: 'Salary',
access: {
resource: 'employee.salary',
action: 'read',
onUnauthorized: 'hide', // non-HR users won't see this column at all
},
},
]}
getRowId={(r) => r.id}
access={{
resource: 'users',
action: 'list',
onUnauthorized: 'hide',
}}
/>Five variants for the row-division style. lines (default) is
the Stripe-inspired clean look — subtle horizontal dividers, no
zebra.
<Table variant="plain" {...props} /> // minimal separators
<Table variant="lines" {...props} /> // default — Stripe-style
<Table variant="striped" {...props} /> // alternating row bg
<Table variant="bordered" {...props} /> // full cell borders
<Table variant="card" {...props} /> // each row as a separate surface cardsm / md (default) / lg — drives text size on every interactive
element.
compact (40px row) / comfortable (default 48px) / spacious (56px) —
the 3-tier from Pencil & Paper UX research. Preserves the same
column / content layout, just changes vertical padding.
sx (root override)<Table sx="border-2 border-dashed" {...props} />slotProps15+ named slots covering every internal element:
| Slot | Element |
|---|---|
root | outer <div> |
toolbar | search + filter row above table |
search | search input wrapper |
scroll | <div class="overflow-auto"> wrapping the table |
table | <table> element |
thead / tbody | their respective elements |
headerRow / headerCell | <tr> / <th> in header |
row / cell | <tr> / <td> in body |
selectionCell / expandToggleCell / rowActionsCell | reserved cells |
expandedRow | full-width row rendered after an expanded row |
emptyState / loadingOverlay | placeholder elements |
bulkActionFooter | sticky-bottom action bar |
<Table
rows={users}
cols={cols}
getRowId={(r) => r.id}
slotProps={{
row: { className: 'data-[selected=true]:bg-emerald-50' },
headerCell: { className: 'uppercase tracking-wide text-xs' },
bulkActionFooter: { className: 'bg-emerald-50 dark:bg-emerald-950' },
}}
/><Table
rows={users}
cols={cols}
getRowId={(r) => r.id}
loading={isFetching}
loadingRowCount={5} // skeleton row count, default 5
/>When loading={true}, the body renders loadingRowCount
Skeleton rows (the Sprint 4 <Skeleton> component). Each
skeleton row carries aria-busy="true" so screen readers announce
the loading state correctly.
Pre-built renderers for common patterns:
import {
RenderText,
RenderTwoLine,
RenderChip,
RenderButton,
RowActionsMenu,
} from '@dashforge/tw';
// One-line, optionally truncated / muted
<RenderText value="Hello" truncate muted />
// Two-line: bold primary + muted secondary (name + email pattern)
<RenderTwoLine primary="Jane Doe" secondary="[email protected]" />
// Status badge with 7 intents + 3 variants
<RenderChip color="success" variant="soft">active</RenderChip>
// Inline button (defaults to ghost variant, size sm)
<RenderButton label="Edit" onClick={onEdit} />
// Row actions 3-dot menu (uses Popover + per-action RBAC)
<RowActionsMenu row={row} actions={[…]} />Use them inside a cellRenderer:
{
field: 'name',
header: 'Name',
cellRenderer: ({ row }) => (
<RenderTwoLine primary={row.name} secondary={row.email} />
),
}Alice Anderson[email protected] | admin | active | |
Bob Brown[email protected] | editor | pending | |
Charlie Cole[email protected] | viewer | blocked |
import {
Table,
RenderTwoLine,
RenderChip,
RowActionsMenu,
type TableColumn,
} from '@dashforge/tw';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
status: 'active' | 'pending' | 'blocked';
}
const USERS: User[] = [
{ id: '1', name: 'Alice Anderson', email: '[email protected]', role: 'admin', status: 'active' },
{ id: '2', name: 'Bob Brown', email: '[email protected]', role: 'editor', status: 'pending' },
{ id: '3', name: 'Charlie Cole', email: '[email protected]', role: 'viewer', status: 'blocked' },
];
const COLS: TableColumn<User>[] = [
{
field: 'name',
header: 'User',
sortable: true,
cellRenderer: ({ row }) => (
<RenderTwoLine primary={row.name} secondary={row.email} />
),
},
{
field: 'role',
header: 'Role',
sortable: true,
cellRenderer: ({ value }) => (
<RenderChip color={value === 'admin' ? 'primary' : 'neutral'}>
{String(value)}
</RenderChip>
),
},
{
field: 'status',
header: 'Status',
sortable: true,
cellRenderer: ({ value }) => (
<RenderChip
color={
value === 'active'
? 'success'
: value === 'pending'
? 'warning'
: 'danger'
}
>
{String(value)}
</RenderChip>
),
},
];
<Table
rows={USERS}
cols={COLS}
getRowId={(row) => row.id}
rowActions={(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' },
]}
/>
)}
sx="w-full max-w-2xl"
/><table> element with <th scope="col"> and proper
<thead> / <tbody> structure (W3C WAI Table tutorial pattern).aria-sort="ascending" | "descending" | "none" on every sortable
header.aria-selected on rows when selection is active.aria-expanded on the expand toggle buttons.<caption> (optional) — by default sr-only, visible via
showCaption.labels prop for i18n.By default every state piece (sort, search, filter, selection,
expansion) lives inside the component. Pass the corresponding
controlled prop + change handler to lift state up — same pattern
as <TextField> value.
// Uncontrolled (sensible defaults — recommended for most cases)
<Table rows={users} cols={cols} getRowId={(r) => r.id} enableSearch rowSelection="multiple" />
// Fully controlled (URL state, persistence, cross-component sync)
const [sort, setSort] = useState<TableSortModel>([]);
const [query, setQuery] = useState('');
const [selected, setSel] = useState<string[]>([]);
const [expanded, setExp] = useState<string[]>([]);
<Table
rows={users}
cols={cols}
getRowId={(r) => r.id}
sortModel={sort} onSortChange={setSort}
searchQuery={query} onSearchQueryChange={setQuery}
selectedRowIds={selected} onSelectionChange={setSel}
expandable={{ render: () => null, expandedRowIds: expanded, onExpandChange: setExp }}
enableSearch
rowSelection="multiple"
/>| Prop | Type | Default | Notes |
|---|---|---|---|
rows | T[] | — | Source data |
cols | TableColumn<T>[] | — | Column definitions |
getRowId | (row, i) => string | (_, i) => String(i) | Stable row key |
sortModel / onSortChange / defaultSortModel | TableSortModel | [] | Controlled/default sort |
enableSearch | boolean | false | Render search input above table |
searchQuery / onSearchQueryChange | string | — | Controlled search |
searchPlaceholder | string | 'Search...' | Placeholder text |
searchDebounceMs | number | 200 | Debounce window |
filterModel / onFilterChange | TableFilterModel | [] | Controlled filter (per-column UI ships in v1-bis) |
rowSelection | 'none' | 'single' | 'multiple' | 'none' | Selection mode |
selectedRowIds / onSelectionChange | string[] | — | Controlled selection |
bulkActions | (rows: T[]) => ReactNode | — | Sticky-bottom action bar |
expandable | { render, expandedRowIds?, onExpandChange? } | — | Expandable rows |
rowActions | (row: T) => ReactNode | — | Per-row actions slot |
loading | boolean | false | Render skeleton rows |
loadingRowCount | number | 5 | 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 | — | Table-level RBAC |
labels | TableLabels | English defaults | i18n strings |
variant | 'plain' | 'lines' | 'striped' | 'bordered' | 'card' | 'lines' | Visual style |
size | 'sm' | 'md' | 'lg' | 'md' | Text size |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Row height |
sx | string | — | Root override |
slotProps | TableSlotProps | — | Per-slot overrides |
TableColumn<T>)| Field | Type | Notes |
|---|---|---|
field | NestedKeyOf<T> | string | Dotted path — 'address.city' supported |
header | string | (() => ReactNode) | Pass t('...') for i18n |
width | number | Pixel width |
flex | number | Flex grow factor |
minWidth | number | For resize (v1-bis) |
align | 'left' | 'right' | 'center' | Auto-detected if omitted |
tabularNums | boolean | Digit-grid alignment (font-variant-numeric: tabular-nums). Does NOT change font family. Auto-true for number / date. |
monospace | boolean | Opt-IN to font-mono. Library never auto-applies it — the font family stays whatever the consumer's theme provides. |
sortable | boolean | ((a, b) => number) | Default or custom comparator |
searchable | boolean | Include in global search |
filterable | boolean | Reserved for v1-bis per-column filter |
cellRenderer | (ctx) => ReactNode | Custom render: ctx = { row, value, rowIndex, isSelected, isExpanded, access } |
access | AccessRequirement | Per-column RBAC |
getNestedValue(row, path) — same helper used internally; useful
in your own custom cell renderers that re-look-up the value with
a different transform.| Sprint | Feature |
|---|---|
| 4.1 (this one) | Core Table — declarative API, sort, search, selection, expandable, row actions, RBAC, smart defaults, i18n |
| 4.1-bis | Per-column filter chips, column visibility dialog, drag-reorder, resize |
| 4.2 | DataGrid — virtualization (homemade IntersectionObserver-based), advanced filter model, 10k+ rows |
| 5+ | Inline cell editing, server-side data layer, headless escape-hatch hook |
RowActionsMenusx vs slotProps decision tree