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"
/>Configure <Table> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Table: {
defaults: { variant: 'lines', size: 'md', density: 'comfortable', stickyHeader: true },
},
},
});Configurable axes (TableVariantProps):
| Axis | Type | Notes |
|---|---|---|
variant | 'plain' | 'lines' | 'striped' | 'bordered' | 'card' | Overall visual treatment. |
size | 'sm' | 'md' | 'lg' | Text size + padding density. |
density | 'compact' | 'comfortable' | 'spacious' | Row height (independent from size — density is a spacing axis, size is a typography axis). |
stickyHeader | boolean | Pin <thead> during scroll (position: sticky; top: 0). |
Non-visual axes (rows, cols, getRowId, sort / search / filter / selection state and handlers, bulkActions, expandable, rowActions, loading, emptyState, caption, access, labels) are per-instance — data / behavior / i18n — not theme-configurable.
Precedence chain (lowest → highest):
defaultVariants from the internal tableVariants recipe (variant: 'lines', size: 'md', density: 'comfortable', stickyHeader: true).theme.components.Table.defaults (application-wide).<Table variant="striped" density="compact" />) — wins over theme.TypeScript: theme.components.Table.defaults autocompletes to the four axes above.
Reactivity: useComponentDefaults('Table') subscribes to the theme store — patchTheme re-renders every mounted instance inheriting the changed axis.
| Prop | Type | Default | Description |
|---|---|---|---|
cols | TableColumn<T>[] | — | Column definitions — see TableColumn<T> for the full shape. |
rows | T[] | — | Source rows to render. Order is display order (before sort). |
access | AccessRequirement | — | Table-level RBAC. hide returns null; disable greys the whole table; readonly disables interaction but keeps content. |
bulkActions | (selectedRows: T[]) => ReactNode | — | Sticky bulk-action footer (visible only when selection > 0). |
caption | ReactNode | — | Optional caption — screen readers announce it as the table's name. |
defaultSortModel | TableSortModel | — | Default sort applied when uncontrolled — mirrors defaultChecked / defaultValue semantics. |
density | 'comfortable' | 'compact' | 'spacious' | 'comfortable' | Row height — independent from size (density is a spacing axis, size is a typography axis). |
emptyState | ReactNode | — | Rendered when rows.length === 0. Falls back to a labeled "No data" state. |
enableSearch | boolean | false | Render the search input above the table. When false, the toolbar row collapses to zero height. |
expandable | TableExpandableConfig<T> | — | Enables the expandable-row column — render(row) returns the detail JSX, and expandedRowIds + onExpandChange control state. |
filterModel | TableFilterModel | — | Controlled per-column filter model — array of active filter clauses (per-column UI shipped in 0.8.0-beta; set cols[i].filterable=true to expose the header menu). |
getRowId | (row: T, index: number) => string | — | Stable row-id resolver — the React key + the identity used by selection and row-expansion state. Strongly recommended whenever sort / search / selection / expandable rows are used. The default falls back to the positional index ( String(index)); once rows reorder (sort) or change presence (search / filter), index 2 points at a different row, so selection and expansion state silently jump to the wrong rows. Pass a value derived from the data itself:tsx <Table getRowId={(row) => row.id} … /> Dashforge emits a console.warn in development when getRowId is omitted and one of those features is active. (<DataGrid> makes this prop required for the same reason.) |
labels | TableLabels | — | i18n string overrides — see TableLabels for the full shape. |
loading | boolean | false | When true, render loadingRowCount skeleton rows instead of the data. |
loadingRowCount | number | 5 | Number of skeleton rows shown while loading=true. |
onFilterChange | (model: TableFilterModel) => void | — | Called when the user adds / removes / edits a per-column filter clause. |
onSearchQueryChange | (q: string) => void | — | Called on every keystroke in the search input (post-debounce). |
onSelectionChange | (ids: string[]) => void | — | Called when the selection changes (checkbox click, shift-range, select-all). |
onSortChange | (model: TableSortModel) => void | — | Called when the user cycles a column header (single-sort) or shift-clicks (multi-sort). |
rowActions | (row: T) => ReactNode | — | Per-row action slot — receives the row, returns JSX rendered on hover. |
rowSelection | TableRowSelectionMode | 'none' | Row selection mode — none (default), single, multiple. |
searchDebounceMs | number | 200 | Debounce window for onSearchQueryChange, in milliseconds. |
searchPlaceholder | string | 'Search…' (from labels) | Placeholder text of the search input. |
searchQuery | string | — | Controlled search query. Pass with onSearchQueryChange. |
selectedRowIds | string[] | — | Controlled selected-row ids. Pass with onSelectionChange. |
showCaption | boolean | false | Render the caption visually too. When false, the caption is sr-only. |
size | 'md' | 'sm' | 'lg' | 'md' | Text-size + padding density. |
slotProps | TableSlotProps | — | Per-slot className overrides — see TableSlotProps. |
sortModel | TableSortModel | — | Controlled sort model — an ordered array of { field, direction } items (multi-sort). Pass with onSortChange to lift state up. Omit for uncontrolled (Table owns the sort state internally). |
stickyHeader | boolean | true | Pin <thead> during vertical scroll (position: sticky; top: 0). |
sx | string | — | Escape hatch for utility classes — appended to the root. Wins over variant classes via tailwind-merge. |
variant | 'plain' | 'lines' | 'striped' | 'bordered' | 'card' | 'lines' | Overall visual treatment — plain, lines (default), striped, bordered, or card (rounded surface with elevation). |
<Table> is a compound component with named slots for each anatomical region. Each slot accepts a { className?: string } override via slotProps:
| Slot | Purpose |
|---|---|
root | Outer wrapper containing toolbar + table + bulk-actions bar. |
toolbar | The above-table region (search + filter chips). |
table | The <table> element itself. |
thead | The sticky (or not) header container. |
headerRow | Each <tr> in the header. |
headerCell | Each <th>. |
sortIndicator | The ▲/▼ glyph attached to a sortable header cell. |
body | The <tbody> container. |
row | Each data <tr>. |
cell | Each <td>. |
selectedRow | Additively applied when a row is selected. |
expandedRow | The inner <tr> rendered by expandable.render. |
emptyState | The zero-rows placeholder container. |
bulkActions | The sticky-bottom action bar (rendered when bulkActions prop returns non-null). |
Use sx for a root-level override; use slotProps for a specific region (e.g. row hover color, headerCell font-weight, emptyState height).
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