DataTable
A table for a lot of rows: it renders only the ones on screen, sorts and searches them, and lets them be chosen the way a file manager does. Reach for it when a grid of data is something to work in rather than something to read.
import { DataTable, type DataTableColumn } from 'neba';
const headers: DataTableColumn<Build>[] = [
{ key: 'id', label: 'Build', width: 90, align: 'end' },
{ key: 'branch', label: 'Branch', width: 180 },
{ key: 'duration', label: 'Duration', align: 'end', render: (row) => `${row.duration}s` }
];
<DataTable
headers={headers}
items={builds}
getRowKey={(row) => row.id}
height={280}
selectionMode="multiple"
sortable
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columnOrder | readonly string[] | — | The order the columns are drawn in, as keys. A key it does not mention keeps its place, so a column added later does not vanish out of a stored order |
| defaultColumnOrder | readonly string[] | — | The initial order |
| onColumnOrderChange | (order: string[]) => void | — | Called when the order changes |
| reorderable | boolean | false | Lets a header be dragged to move its column. Off by default because a header is a control: a table whose columns move when a reader meant to sort is worse than one whose columns do not move |
| onCellEdit | (row, column, value: string | number) => void | — | Called when an edited cell is committed. Nothing is editable without it: the table holds no copy of the rows, it hands the value over and draws what comes back in items |
| groupBy | (row: Row) => string | undefined | — | Groups the rows under a heading. It runs after the search and the sort, so each group stays sorted; a row it returns undefined for goes above everything, in no group. It turns virtual scrolling off |
| collapsibleGroups | boolean | true | Whether a group can be folded away |
| defaultCollapsedGroups | readonly string[] | — | Which groups start folded |
| exportable | boolean | false | Adds a button that writes the rows out as CSV: every row the search and the sort left, not the page the reader is on |
| exportFileName | string | 'table.csv' | What the downloaded file is called |
| onExport | (csv: string) => void | — | Takes the CSV instead of downloading it |
| variantshared | 'solid' | 'outline' | 'text' | 'outline' | Weight of the surface, passed straight to the Box |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'sm' | Type scale and padding of the cells, and the default rowHeight. One step tighter than Table's |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. The sheet stays white, so it reaches the hairline, the chosen rows and the sorted column |
| densityshared | 'default' | 'compact' | 'compact' | Padding only: though here it also lowers the default rowHeight |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| headers * | readonly DataTableColumn[] | — | The columns: see DataTableColumn below |
| items * | readonly Row[] | — | The rows |
| getRowKey | (row, index) => Key | index | A stable identity per row, and what selected is a list of. Required in practice the moment rows can be chosen, sorted or filtered |
| height | number | string | — | The height of the scrolling body. **This is what turns virtual scrolling on** |
| maxHeight | number | string | — | The same, as a ceiling: as tall as its rows, up to this |
| rowHeight | number | size/density | How tall one row is, in pixels. Every row is this tall and cells truncate rather than wrap |
| virtual | boolean | true | Leaves off-screen rows out of the DOM. Needs a height or a maxHeight to do anything |
| overscan | number | 8 | How many rows are kept rendered past each edge of the viewport |
| striped | boolean | 'odd' | 'even' | false | Tints every other row. true means even, and the parity is counted over the whole set |
| hoverable | boolean | true | Lights the row under the pointer |
| stickyHeader | boolean | true | Pins the header while the body scrolls |
| caption | ReactNode | — | Shown above the table, and read as its accessible name |
| label | string | — | The name the grid is announced by, when there is no caption |
| empty | ReactNode | — | What to show instead of rows when there are none |
| sortable | boolean | false | Makes every column sortable. A column overrides it with its own sortable |
| sortMode | 'single' | 'multiple' | 'single' | With multiple, a Shift-click adds a column to the sort instead of replacing it |
| sort / defaultSort | readonly DataTableSort[] | — | The sort: a list of { key, direction }, first key outermost |
| onSortChange | (sort: DataTableSort[]) => void | — | Fires when the sort changes |
| resizable | boolean | false | Lets the headers be dragged wider. A double-click on the handle restores the original width |
| columnWidths / defaultColumnWidths | Record<string, number> | — | The widths, in pixels, keyed by column |
| onColumnWidthsChange | (widths) => void | — | Fires when a column is resized |
| selectionMode | 'none' | 'single' | 'multiple' | 'none' | How many rows may be chosen at once |
| selected / defaultSelected | readonly Key[] | — | The chosen rows, as their keys |
| onSelectedChange | (keys: Key[], rows: Row[]) => void | — | The keys, and the rows behind them: including rows on other pages |
| checkboxes | boolean | false | Adds a column of ticks, and one in the header that chooses every displayed row at once |
| onRowClick | (row, index, event) => void | — | Fires on every press of a row, before the selection changes |
| onRowActivate | (row, index) => void | — | Fires on a double-click and on Enter: opening the row is what this is |
| paging | 'scroll' | 'pages' | 'scroll' | Whether the rows arrive all at once or a page at a time |
| page / defaultPage | number | 1 | The current page, 1-based |
| onPageChange | (page: number) => void | — | Fires when the page changes |
| pageSize / defaultPageSize | number | 25 | How many rows a page holds |
| onPageSizeChange | (pageSize: number) => void | — | Fires when the page size changes |
| pageSizeOptions | readonly number[] | [10, 25, 50, 100] | What the footer's page-size Select offers. An empty list drops the control |
| footer | boolean | paging === 'pages' | The bar under the table: how many rows there are, how many are chosen, and the pages |
| search / defaultSearch | string | — | The query every searchable column is matched against |
| onSearchChange | (search: string) => void | — | Fires when the query changes |
| searchable | boolean | false | Draws the search field above the table |
| searchPlaceholder | string | — | The field's placeholder and its accessible name |
| filter | (row, index) => boolean | — | A filter of your own, applied after the search. Return false to drop a row |
| toolbar | ReactNode | — | Content at the end of the bar the search field sits in |
| manual | boolean | ('sort' | 'filter' | 'pages')[] | false | Which stages the caller has already done. true is all three |
| rowCount | number | — | How many rows there are in total, when the table is not doing the paging |
| locale | string | 'en' | The language the table's own words are in, and what the default sort compares strings with |
The outer sheet is a Box: variant · size · color · density · elevation all pass straight through. Everything else a <div> takes (id, data-*, onContextMenu) lands on it too.
Define headers outside the component, or memoise it. The search and the sort are keyed on that array's identity, and an inline literal is a new array on every render.
DataTableColumn
| Prop | Type | Default | Description |
|---|---|---|---|
| pinned | 'start' | 'end' | — | Freezes the column against that edge while the rest scroll past. It also moves it: start first, end last. Give it a width: the sticky offsets are the sum of the widths before them, and a column with none has no number to add |
| editable | boolean | ((row: Row) => boolean) | — | Lets a cell in this column be edited in place; a function decides per row. Not editable at all without an onCellEdit above it |
| editType | 'text' | 'number' | 'text' | What kind of field the editor is. number keeps the keypad on a phone and hands back a number rather than a string |
| aggregate | (rows: Row[]) => ReactNode | — | The one value that stands for a group, drawn in the heading row in its own column: a total belongs in the column of the numbers it is a total of |
| exportValue | (row: Row) => unknown | — | What an export writes for this cell. Separate from render: a cell that draws a Chip has no text to put in a file |
| exportable | boolean | true | Leaves this column out of an export |
| key * | string | — | Identifies the column, and unless value or render says otherwise, names the property to read off each row |
| label | ReactNode | key | The heading |
| group | string | — | Adjacent columns carrying the same string are drawn under one merged cell in a second header row |
| width | number | — | How wide, in pixels. Columns that do not say share whatever is left |
| minWidth | number | 48 | How narrow a drag may make it |
| align / headerAlign | 'start' | 'center' | 'end' | 'start' | Which edge the cells and the heading line up against. Numbers usually want end |
| sortable / resizable | boolean | 표의 값 | Overrides the table's own sortable and resizable for this column |
| searchable | boolean | true | Whether the search looks in this column |
| hidden | boolean | — | Leaves the column out without removing it from the list |
| value | (row) => unknown | row[key] | The value behind the cell: what is sorted, and what the search is matched against |
| compare | (a, b) => number | — | Orders two rows by this column when the default comparison cannot. Always written ascending; the table reverses it |
| render | (row, index) => ReactNode | — | Draws the cell. index is the row's place in the sorted, filtered order, counted across every page |
render decides what a reader sees; value decides what the sort and the search see. A column that draws a Chip needs render, and it needs value as well the moment it is sortable.
Examples
Virtual scrolling
Set a height (or a maxHeight) and the body scrolls with only the visible rows in the DOM. Without one there is nothing to measure against, so every row is rendered whatever virtual says, and virtual={false} turns it off for a table small enough that find-in-page matters more than the DOM count.
Every row is rowHeight tall and cells truncate rather than wrap, which is what makes the scroll offset arithmetic. Raise rowHeight for cells holding an Avatar or two lines.
Selecting rows
selectionMode is none, single or multiple. With multiple:
| Click | chooses that row and drops the rest |
| Ctrl/⌘ + click | adds or removes one |
| Shift + click | takes the run from the last row chosen |
| Click and drag | takes the run under the pointer, scrolling at the edges |
| ↑ ↓ | move and choose |
| Home End PageUp PageDown | scroll only: what is chosen stays chosen |
| Ctrl/⌘ + arrows | move without choosing |
| Shift + arrows | extend the run |
| Space | choose the row the focus is on; with Ctrl/⌘, toggle it |
| Ctrl/⌘ + A | every displayed row |
| Esc | nothing |
| Enter, double-click | onRowActivate |
checkboxes adds a tick column and a header tick that chooses every displayed row at once. onSelectedChange reports the keys and the rows behind them, including rows on pages that are not on screen.
Sorting
sortable makes every column sortable and a column overrides it with its own sortable. A heading cycles ascending → descending → unsorted, and aria-sort says which.
With sortMode="multiple", a Shift-click adds a column to the sort rather than replacing it; the number beside the arrow is its place in the order. Give a column compare when its values do not rank alphabetically, and value when the cell is drawn by render.
Column widths and groups
width is pixels, and columns that do not state one share whatever is left. resizable puts a handle on each boundary; the first drag freezes every column at the width the browser had given it, so pulling one moves one. A double-click on a handle gives that column its original width back.
Adjacent columns carrying the same group string are merged under one heading in a second header row. A column with no group spans both rows.
Pages and the footer
paging="pages" cuts the rows into pages and draws a footer: the range, how many rows are chosen, a page-size Select and a Pagination. pageSizeOptions decides what the Select offers, and an empty list drops it.
footer shows or hides that bar on its own, so a scrolling table can have the count without the pages.
Search and filter
search is matched against every column that has not set searchable: false, case- and accent-insensitively, against value where a column has one. searchable draws the field; toolbar fills the rest of the bar it sits in. filter is your own predicate, applied after the search.
Pinned columns
pinned: 'start' | 'end' on a column freezes it against that edge while the rest scroll past.
Pinning also moves the column: everything pinned to the start is drawn first and everything pinned to the end last, whatever columnOrder said. A frozen column in the middle of the scrolling ones would slide over its neighbours instead of holding still.
Give a pinned column a width. The offsets the sticky cells sit at are the sum of the widths before them, and a column that has not said how wide it is has no number to add: it is measured at the default instead, which is a guess.
Column order and reordering
columnOrder is a list of keys. A key it does not mention keeps its place, so an order that names two columns moves those two and leaves the rest alone, and a column added to headers later appears without the stored order having to be migrated.
reorderable lets a header be dragged along the row. It is off by default, and the drag arms at a threshold rather than at the press, so a click meant to sort does not move the column. Pinned headers are not draggable, since where they sit is what pinning decided.
Editing a cell
onCellEdit on the table and editable on a column, together:
<DataTable
headers={[{ key: 'name', label: 'Name', editable: true }]}
items={rows}
onCellEdit={(row, column, value) => save(row.id, column.key, value)}
/>Neither works alone. A column with no handler above it is not editable however editable is set, because the table holds no copy of the rows: it hands the new value over and draws whatever comes back in items. A table that wrote into its own copy would be a table showing something the application does not know about.
editable may be a function, for a locked record or a computed field. editType: 'number' keeps the keypad on a phone and hands back a number rather than a string.
A double-click opens the editor; blur and Enter commit, Escape cancels. onRowActivate does not also fire for a cell that opened an editor: the cell answered the double-click.
Grouping and aggregates
groupBy returns a heading for each row, and rows carrying the same one are gathered under it.
<DataTable
headers={[
{ key: 'name', label: 'Name' },
{ key: 'spend', label: 'Spend', aggregate: (rows) => sum(rows) }
]}
items={rows}
groupBy={(row) => row.team}
/>The grouping runs after the search and the sort, so a sorted table stays sorted inside each group and a filtered one groups only what is left. Groups keep the order their first row appeared in: except rows groupBy returned undefined for, which go above everything, because a heading that says nothing is not one a reader can interpret.
aggregate draws in the group heading, in its own column, which is the whole point: a group's total belongs in the same column as the numbers it is a total of. There is no 'sum' | 'avg' shorthand: the moment a table has one column needing a weighted mean or a distinct count, half the columns are functions and half are strings.
Grouping turns virtual scrolling off. The window arithmetic counts every child of the body as one row of rowHeight, and a heading row is one more than that.
Exporting
exportable adds a button that writes the rows out as a CSV file.
Every row the reader is currently looking at, not the page they are on. The search and the sort are applied and the paging is not, because a file of page 3 is not a file anybody asked for.
exportValue on a column is what the file gets, separate from render on purpose: a cell that draws a Chip, an Avatar or a progress bar has no text to put in a file. exportable: false on a column leaves it out.
The file leads with a byte-order mark, and that is not decoration: Excel reads a UTF-8 CSV without one as the local code page, so every non-ASCII name in it arrives as mojibake.
onExport takes the CSV instead of downloading it.
Size and density
size sets the type scale, the cell padding and the default rowHeight; density changes the padding and, here alone, lowers that default with it. The ladder sits one step below the rest of the library: a md row is 32px against a Button's 32px height plus its own padding.
Rows from a server
manual names the stages the caller has already done: 'sort', 'filter', 'pages', or true for all three. The table then draws items as they arrive and only reports what was asked for. With 'pages' in the list, items is one page and rowCount is how many rows there are altogether.
Accessibility
- With a
selectionModethe table is agridwith one tab stop andaria-activedescendant, because a virtual row cannot hold the focus: the row that had it is unmounted the moment it scrolls away. Rows carryaria-selected. - Without one it is a plain
table, and nothing in it takes focus except the sortable headings. - A sortable heading is a real
<button>; the<th>around it carriesaria-sort. - Give the table a
captionor alabel. Without either, a screen reader announces an unnamed grid. - The resize handles are pointer-only and hidden from assistive technology. Column widths are a preference, not information: nothing in the table is unreachable without them.
- Pass
localewhen the markup is rendered on a server: it is what the default sort compares strings with, and a server that disagrees with the browser about the runtime locale produces two different row orders for the same table.