Data · Data table
Data table
A real table, not a spreadsheet. Semantic markup keeps screen readers and browser find-in-page working, while columns describe intent — numeric, currency, percent, status, sparkline — instead of repeating alignment and formatting rules on every screen.
Examples
import {
ColumnVisibilityMenu,
DataTable,
DataTablePagination,
DataTableSelectionCount,
DataTableToolbar,
FacetFilter,
SegmentedControl,
SegmentedItem,
} from "@luc/ui";Full table
Sorting, global search, faceted filters, selection, column visibility, sticky header, pinned edges, expansion and pagination — in one table, all keyboard reachable.
| Expand row | Equity | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| ETH-USD | Trend breakout | Draft | 2.07 | -0.3% | Sep 21 | ||||
| BTC-USD | Volatility carry | Paused | 2.00 | 41.7% | Sep 13 | ||||
| BTC-USD | Funding basis | Draft | 1.97 | 19.5% | Sep 23 | ||||
| GC | Trend breakout | Draft | 1.87 | 30.9% | Sep 22 | ||||
| AAPL | Cross-sectional momentum | Live | 1.73 | -3.8% | Aug 24 | ||||
| GC | Funding basis | Paused | 1.73 | 35.0% | Sep 20 | ||||
| GC | Regime switch | Paused | 1.60 | 1.9% | Sep 8 | ||||
| NDX | Volatility carry | Live | 1.55 | 23.0% | Sep 9 | ||||
| NVDA | Trend breakout | Live | 1.54 | 21.7% | Sep 19 | ||||
| NVDA | Regime switch | Paused | 1.47 | 32.1% | Sep 4 | ||||
| NVDA | Regime switch | Draft | 1.45 | 19.9% | Aug 17 | ||||
| ETH-USD | Volatility carry | Draft | 1.36 | 37.9% | Sep 16 |
1–12 of 48 runs
The state switch is a demo control: the component just renders the state you give it.
<DataTable<ResearchRun>
ariaLabel="Research runs"
columns={columns}
data={RUNS}
getRowId={(row) => row.id}
state={state}
onStateChange={setState}
density="comfortable"
stickyHeader
maxHeight={560}
enableRowSelection
enableExpansion
enableFiltering
enableColumnVisibility
pageSize={12}
defaultSorting={[{ id: "sharpe", desc: true }]}
/>Columns carry the semantics
A column helper decides alignment, tabular figures, em-dash handling and tone. Nothing guesses units: a value formatted as a percentage is one because the column said so.
What the helpers do
- Numeric columns are right-aligned with tabular figures.
- null, NaN and Infinity render an em dash — never “NaN”.
- tone: "signed" uses the semantic positive/negative tokens.
- Filtering is opt-in per column, and the predicate is named.
- Locked columns cannot be hidden; pinned columns stay opaque.
What they never do
- Compute return, volatility, drawdown or any other metric.
- Convert units (0.0001 is never silently 0.01%).
- Fetch data — the table renders the rows you give it.
- Own sort/filter/page state when you control it yourself.
- Turn into a spreadsheet: no cell editing or fill handles.
const columns = [
textColumn("symbol", { header: "Symbol", locked: true, pin: "start" }),
numericColumn("sharpe", { header: "Sharpe", decimals: 2, tone: "signed" }),
percentColumn("cagr", { header: "CAGR", decimals: 1 }),
statusColumn("status", { header: "Status", filterable: true, filterFn: "arrHas", map: toStatus }),
];
<DataTable columns={columns} data={runs} getRowId={(row) => row.id} ariaLabel="Research runs" />Server-driven, without a rewrite
Pass manualPagination, manualSorting and the matching controlled state and the table stops slicing locally. Row identity, selection and column visibility keep working because they were never tied to the client row model.
The toolbar is a slot. LUC ships the bound pieces — search, selection count, column menu, facets, pagination — and stays out of the rest.
<DataTable
manualPagination
manualSorting
manualFiltering
pageCount={pageCountFromServer}
rowCount={totalRows}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={setSorting}
columns={columns}
data={pageOfRows}
ariaLabel="Research runs"
/>API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
DataTable | 57 documented props | <TData extends RowData>({ ariaLabel, ariaDescription, caption, state: tableState, loadingRows, emptyState, errorState, density, stickyHeader, maxHeight, showColumnBorders, toolbar, footer, renderSubR… |
DataTable props
Type: DataTableProps<TData>
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
ariaDescription? | string | undefined | — | Longer description announced with the table. |
ariaLabel | string | — | Accessible name for the table. Required — an unnamed table is a defect. |
caption? | ReactNode | — | |
className? | string | undefined | — | |
columnFilters? | ColumnFiltersState | undefined | — | |
columnPinning? | ColumnPinningState | undefined | — | |
columns | readonly DataTableColumn<TData>[] | — | |
columnVisibility? | ColumnVisibilityState | undefined | — | |
data | TData[] | — | |
defaultColumnFilters? | ColumnFiltersState | undefined | — | |
defaultColumnPinning? | ColumnPinningState | undefined | — | |
defaultColumnVisibility? | ColumnVisibilityState | undefined | — | |
defaultExpanded? | ExpandedState | undefined | — | |
defaultGlobalFilter? | string | undefined | — | |
defaultPagination? | PaginationState | undefined | — | |
defaultRowSelection? | RowSelectionState | undefined | — | |
defaultSorting? | SortingState | undefined | — | |
density? | DataTableDensity | undefined | — | |
emptyState? | ReactNode | — | |
enableColumnVisibility? | boolean | undefined | — | |
enableExpansion? | boolean | undefined | — | |
enableFiltering? | boolean | undefined | — | |
enableGlobalFilter? | boolean | undefined | — | |
enableRowSelection? | boolean | "single" | undefined | — | Adds a leading checkbox column. `"single"` restricts to one row. |
enableSorting? | boolean | undefined | — | |
errorState? | ReactNode | — | |
expanded? | ExpandedState | undefined | — | |
footer? | ReactNode | — | Rendered inside the table context, below the scroll area. |
getRowId? | ((row: TData, index: number) => string) | undefined | — | Stable row identity. Required for selection to survive sorting/filtering. |
globalFilter? | string | undefined | — | |
loadingRows? | number | undefined | — | |
manualFiltering? | boolean | undefined | — | |
manualPagination? | boolean | undefined | — | Server-side modes: the table stops slicing and expects prepared data. |
manualSorting? | boolean | undefined | — | |
maxHeight? | string | number | undefined | — | Constrains the scroll area, which is what makes a sticky header useful. |
onColumnFiltersChange? | ((filters: ColumnFiltersState) => void) | undefined | — | |
onColumnPinningChange? | ((pinning: ColumnPinningState) => void) | undefined | — | |
onColumnVisibilityChange? | ((visibility: ColumnVisibilityState) => void) | undefined | — | |
onExpandedChange? | ((expanded: ExpandedState) => void) | undefined | — | |
onGlobalFilterChange? | ((value: string) => void) | undefined | — | |
onPaginationChange? | ((pagination: PaginationState) => void) | undefined | — | |
onRowClick? | ((row: TData) => void) | undefined | — | |
onRowSelectionChange? | ((selection: RowSelectionState) => void) | undefined | — | |
onSortingChange? | ((sorting: SortingState) => void) | undefined | — | |
pageCount? | number | undefined | — | Total pages / rows known to the server. |
pageSize? | number | undefined | — | Default page size when the caller does not control pagination. |
pagination? | PaginationState | undefined | — | |
renderSubRow? | ((row: TData) => ReactNode) | undefined | — | Expanded row content. Keep it short; this is a detail strip, not a page. |
rowClassName? | ((row: TData) => string | undefined) | undefined | — | |
rowCount? | number | undefined | — | |
rowSelection? | RowSelectionState | undefined | — | |
scrollClassName? | string | undefined | — | |
showColumnBorders? | boolean | undefined | — | Vertical rules between cells. Off by default — hairlines beat a grid. |
sorting? | SortingState | undefined | — | |
state? | DataTableState | undefined | — | `loading` and `error` replace the body; `empty` covers zero filtered rows. |
stickyHeader? | boolean | undefined | — | |
toolbar? | ReactNode | — | Rendered inside the table context, above the scroll area. |
Usage
import { DataTable } from "@luc/ui/data-table";<DataTable
columns={columns}
data={runs}
ariaLabel="Research runs"
enableSorting
enableFiltering
/>Guidelines
Do
- Always pass `ariaLabel` or `caption`; that is what names the table for assistive technology.
- Use the built-in `enableSorting`, `enableFiltering` and `enableRowSelection` instead of rebuilding them beside the table, so the header, toolbar and pager read one state.
Don't
- Render a table for key/value pairs: two columns and six rows are a definition list, and `DataDetails` already gives it a surface.
- Format numbers inside a cell by hand: return the formatted string from the column definition so the table does not own a locale decision.
Accessibility
It renders a real table with a caption, `th scope="col"` headers and `aria-sort` on the sorted column. Selection and expansion controls carry names such as `Select row 3` and `Collapse row`.
Related