DataTable
@devalok/shilp-sutra/ui/data-tableView in Storybook Preview
| Orbit redesign | Aisha Kapoor | Active | 128 |
| Karm mobile app | Ben Carter | Review | 64 |
| Setu brand kit | Chen Wei | Draft | 12 |
| Docs revamp | Diego Alvarez | Active | 47 |
| Billing v2 | Erin Shah | Review | 89 |
Variants
| Orbit redesign | Aisha Kapoor | Active | 128 |
| Karm mobile app | Ben Carter | Review | 64 |
| Setu brand kit | Chen Wei | Draft | 12 |
| Docs revamp | Diego Alvarez | Active | 47 |
| Billing v2 | Erin Shah | Review | 89 |
| Orbit redesign | Aisha Kapoor | Active | 128 | |
| Karm mobile app | Ben Carter | Review | 64 | |
| Setu brand kit | Chen Wei | Draft | 12 |
Reference
Props
columns: ColumnDef<TData, TValue>[] (TanStack column definitions)
data: TData[]
className: string — class name for the wrapper div
sortable: boolean — enable column sorting
onSort: (key: string, direction: 'asc' | 'desc' | false) => void — server-side sort callback (enables manualSorting)
filterable: boolean — enable per-column filters
filterableColumns: string[] — restrict filter inputs to these column IDs (only with filterable; omit for all filterable columns)
globalFilter: boolean — enable global search
paginated: boolean — enable client-side pagination
pagination: { page: number, pageSize: number, total: number, onPageChange: (page: number) => void } — server-side pagination (1-based page)
pageSize: number (default 10)
pageSizeOptions: number[] — page-size selector options (default [10, 20, 50, 100])
selectable: boolean — enable row selection with checkboxes
selectedIds: Set<string> — controlled selection state
selectableFilter: (row: TData) => boolean — disable selection on certain rows
getRowId: (row: TData) => string — custom row ID accessor
onSelectionChange: (selectedRows: TData[], selectedIds: Set<string>) => void — does NOT fire on mount
expandable: boolean — enable row expansion
renderExpanded: (row: TData) => ReactNode — expanded row content
singleExpand: boolean — only one row expanded at a time
loading: boolean — show skeleton shimmer rows
emptyState: ReactNode — custom empty state (takes precedence over noResultsText)
noResultsText: string (default "No results.")
stickyHeader: boolean — sticky table header
onRowClick: (row: TData) => void — row click handler (excludes interactive element clicks)
rowClassName: (row: TData) => string | undefined — conditional per-row class (the <tr> in table mode, the Card in card mode)
bulkActions: BulkAction<TData>[] — floating action bar on selection — { label, onClick, icon?: IconInput, color?: 'accent'|'error', disabled? }
bulkActionsPosition: 'bottom' | 'top' | 'inline' — where the bulk-actions bar renders (default 'bottom')
toolbar: boolean — show DataTableToolbar (column visibility, density, CSV export)
enableExport: boolean — show the toolbar's Export CSV button (default true)
onExport: (visibleRows: TData[]) => void — replace the built-in CSV export
editable: boolean — enable double-click cell editing
onCellEdit: (rowIndex: number, columnId: string, value: unknown) => void — fired on cell edit commit
virtualRows: boolean — virtualize rows for large datasets
virtualRowHeight: number — ESTIMATED row height in px (default 48); real heights are measured after mount
maxHeight: number — max height of the virtual scroll container in px (default 600)
mobileView: 'card' | 'table' — stacked cards below the sm breakpoint (default 'table')
columnPinning: { left?: string[], right?: string[] }
density: 'compact' | 'standard' | 'comfortable'
Defaults
pageSize=10, noResultsText="No results.", enableExport=true, mobileView='table', virtualRowHeight=48, maxHeight=600, density='standard'
Example
import { DataTable } from '@devalok/shilp-sutra/ui/data-table'
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
]}
data={users}
sortable
onSort={(key, dir) => handleSort(key, dir)}
pagination={{ page, pageSize: 20, total: totalCount, onPageChange: setPage }}
loading={isLoading}
emptyState={<EmptyState title="No users" />}
/>Composability
Server vs client mode is prop-driven, not explicit.
- Pass
onSort→ server-side sort (manual, rows stay in data order — you're responsible for re-fetching). - Pass
paginationobject → server-side pagination (manual, pass total count). - Omit both → client-side sort/pagination via TanStack react-table.
- Mix-and-match:
onSort+ no pagination = server sort + client pagination.
Companion components:
DataTableToolbar— enabled viatoolbar={true}. Provides column visibility, density switcher, CSV export. Reads table state viaDataTableContext(internal). Rendered ABOVE the table automatically.BulkActionBar(floating) — appears when rows are selected ANDbulkActionsarray is non-empty. Synced withselectedIds; shows count + action buttons.EmptyStatefrom@devalok/shilp-sutra/composed— pass toemptyStateprop. Takes precedence overnoResultsTextstring.
Controlled selection:
- Pass
selectedIds(Set<string>) +onSelectionChangefor controlled row selection. - Provide
getRowId: (row) => row.idso selection survives data refetches (otherwise TanStack uses array index, which breaks on sort/filter). selectableFilter: (row) => booleandisables selection on specific rows (e.g. archived items).
Row click model:
onRowClickfires on row-level click BUT excludes clicks on checkboxes, buttons, links, and inputs automatically. No manualstopPropagationneeded for standard interactive elements.
Virtualization: virtualRows={true} enables row virtualization via @tanstack/react-virtual. Turn it on for 1000+ row datasets; the scroll container must have a bounded height. Rows stay in normal table flow (each windowed row is its own <tbody> measured by the virtualizer, with spacer row groups reserving the un-rendered remainder), so column widths keep tracking <thead> and virtualRowHeight is only the pre-measurement estimate.
Virtualization + expansion: virtualRows and expandable compose. Because each row group is measured, an expanded detail panel of any height contributes to the total scroll size and pushes the rows below it down. The reveal is instant in virtual mode (no height animation) — an animating height would fire a resize on every frame.
Toolbar export: the Export button renders whenever toolbar is on. The built-in CSV export walks getFilteredRowModel(), which under server-side pagination is only the current page — pass onExport to fetch the full set yourself, or enableExport={false} to drop the button.
Density integration: density is forwarded to Table's density prop, which sets --table-py (compact 4 / standard 8 / comfortable 12px → rows ≈ 29 / 37 / 45px; header tracks it). DataTableToolbar's density switcher updates this at runtime; the prop sets the initial state only.
Gotchas
- Barrel-isolated since v0.5.0 — must use
@devalok/shilp-sutra/ui/data-table, NOT theuibarrel - Requires @tanstack/react-table and @tanstack/react-virtual as peer dependencies
- When onSort is provided, sorting is manual (server-side) — rows stay in data order
- When pagination prop is provided, pagination is manual — pass total count
- selectedIds syncs via useEffect — provide getRowId for custom row IDs
- onRowClick does NOT fire when clicking checkboxes, buttons, links, or inputs
- Use density="compact" for Karm-style h-9 rows
virtualRows={true}requires a bounded scroll container — unbounded height silently disables virtualizationonSelectionChangedoes NOT fire on mount, and does NOT fire when selection is synced from theselectedIdsprop — only on genuine selection changesfilterableColumnsis ignored unlessfilterableis also setrowClassNamereturns are passed throughcn()verbatim — a class that does not exist in the token set silently does nothing (use the real scale steps, e.g.bg-error-3, not invented names likebg-error-subtle)
Changes
Unreleased
- Added
bulkActions[].icon?: IconInput— icon rendered before the label in a bulk-action button. - Added
bulkActionsPosition?: 'bottom' | 'top' | 'inline'— where the bulk-actions bar renders (default'bottom', matching prior behavior).
v0.57.0
- Fixed
onSelectionChangeno longer fires on mount with[]— first-render guard added. Root cause of the cascade reported in #213. - Fixed
virtualRows + expandablewas a silent no-op — the expanded row was only rendered on the non-virtual path. Virtual rows now render one measured<tbody>per windowed row (with spacer row groups for the remainder) so the expanded panel renders, contributes its real height togetTotalSize(), and cannot overlap the row below. - Changed Virtual rows are no longer absolutely positioned with a forced
virtualRowHeight; they sit in normal table flow at their measured height, so column widths track<thead>.virtualRowHeightis now the pre-measurement ESTIMATE. - Fixed
enableExportwas stranded onDataTableToolbarand never wired throughDataTableProps. Now exposed with anonExportoverride. Default is unchanged (true) — the Export button still renders whenevertoolbaris on. - Fixed
filterable + mobileView="card"rendered no filter inputs — they now render above the card list inDataTableCards(card mode renders no<thead>for them to live in). - Added
onSelectionChangereceivesselectedIds: Set<string>as second argument — complement of theselectedIdsprop. - Added
filterableColumns?: string[]— restrict filter inputs to specific column IDs. - Added
rowClassName?: (row: TData) => string | undefined— conditional row classes in table and card layouts. - Added
enableExport?: boolean— hide the toolbar's Export CSV button fromDataTableProps. - Added
onExport?: (visibleRows: TData[]) => void— override built-in CSV with a custom export handler.
v0.45.0
- Fixed Expander a11y per the expando-row spec:
aria-expandedon the toggle button, visually-hidden "Expand rows" column header; chevron rotation usesduration-fast-02 ease-productive-standard. - Added Expanded-row content animates open/closed (height + opacity via framer,
springs.smooth), self-guarded withuseReducedMotion— instant swap for reduced-motion users. Virtualized tables keep the instant reveal (a height animation would fight the virtualizer's measurements). - Changed Density now drives Table's
--table-pyvariable (rows ≈ 29 / 37 / 45px; was 29 / 53 / 85). Per-cellcellPaddingthreading removed from context. - Fixed Hover/selected states visible on cards: row/sort-button/expander hover use
surface-panel-hover(was the invisiblesurface-panel); expanded row is asurface-baserecess; sticky header bg issurface-panel; empty state usespy-ds-07(was rawh-24). - Changed Mobile card view composes
<Card size="sm" variant="outline">(was a hand-rolled 12px bordered box).
v0.29.0
- Fixed Controlled selection infinite re-render loop — inline
getRowIdcallback causedonSelectionChangeeffect to fire every render, creating a setState cycle withselectedIds. Now uses a stable ref forgetRowId.
v0.16.1
- Fixed
serverPaginationobject reference inuseCallbackdependency caused stale closure — now uses stable ref foronPageChange - Fixed
onSelectionChangeeffect fired every render due totablein dependency array — now derives selected rows directly - Fixed
selectedRowsuseMemo for bulk actions had sametabledependency issue
v0.16.0
- Added
onSortcallback for server-side sorting - Added
emptyStateReactNode slot - Added
loadingprop with shimmer skeleton rows - Added
selectedIds+selectableFilterfor controlled selection - Added
paginationprop for server-side pagination - Added
singleExpandprop - Added
stickyHeaderprop - Added
onRowClickhandler - Added
bulkActionsfloating action bar
v0.5.0
- Changed (BREAKING) Removed from
@devalok/shilp-sutra/uibarrel export — must use@devalok/shilp-sutra/ui/data-table
v0.1.1
- Fixed
useEffectexhaustive-deps with proper dependency array
v0.1.0
- Added Initial release