Skip to content

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.

tsx
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

PropTypeDefaultDescription
columnOrderreadonly 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
defaultColumnOrderreadonly string[]The initial order
onColumnOrderChange(order: string[]) => voidCalled when the order changes
reorderablebooleanfalseLets 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) => voidCalled 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 | undefinedGroups 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
collapsibleGroupsbooleantrueWhether a group can be folded away
defaultCollapsedGroupsreadonly string[]Which groups start folded
exportablebooleanfalseAdds a button that writes the rows out as CSV: every row the search and the sort left, not the page the reader is on
exportFileNamestring'table.csv'What the downloaded file is called
onExport(csv: string) => voidTakes 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
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
headers * readonly DataTableColumn[]The columns: see DataTableColumn below
items * readonly Row[]The rows
getRowKey(row, index) => KeyindexA stable identity per row, and what selected is a list of. Required in practice the moment rows can be chosen, sorted or filtered
heightnumber | stringThe height of the scrolling body. **This is what turns virtual scrolling on**
maxHeightnumber | stringThe same, as a ceiling: as tall as its rows, up to this
rowHeightnumbersize/densityHow tall one row is, in pixels. Every row is this tall and cells truncate rather than wrap
virtualbooleantrueLeaves off-screen rows out of the DOM. Needs a height or a maxHeight to do anything
overscannumber8How many rows are kept rendered past each edge of the viewport
stripedboolean | 'odd' | 'even'falseTints every other row. true means even, and the parity is counted over the whole set
hoverablebooleantrueLights the row under the pointer
stickyHeaderbooleantruePins the header while the body scrolls
captionReactNodeShown above the table, and read as its accessible name
labelstringThe name the grid is announced by, when there is no caption
emptyReactNodeWhat to show instead of rows when there are none
sortablebooleanfalseMakes 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 / defaultSortreadonly DataTableSort[]The sort: a list of { key, direction }, first key outermost
onSortChange(sort: DataTableSort[]) => voidFires when the sort changes
resizablebooleanfalseLets the headers be dragged wider. A double-click on the handle restores the original width
columnWidths / defaultColumnWidthsRecord<string, number>The widths, in pixels, keyed by column
onColumnWidthsChange(widths) => voidFires when a column is resized
selectionMode'none' | 'single' | 'multiple''none'How many rows may be chosen at once
selected / defaultSelectedreadonly Key[]The chosen rows, as their keys
onSelectedChange(keys: Key[], rows: Row[]) => voidThe keys, and the rows behind them: including rows on other pages
checkboxesbooleanfalseAdds a column of ticks, and one in the header that chooses every displayed row at once
onRowClick(row, index, event) => voidFires on every press of a row, before the selection changes
onRowActivate(row, index) => voidFires 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 / defaultPagenumber1The current page, 1-based
onPageChange(page: number) => voidFires when the page changes
pageSize / defaultPageSizenumber25How many rows a page holds
onPageSizeChange(pageSize: number) => voidFires when the page size changes
pageSizeOptionsreadonly number[][10, 25, 50, 100]What the footer's page-size Select offers. An empty list drops the control
footerbooleanpaging === 'pages'The bar under the table: how many rows there are, how many are chosen, and the pages
search / defaultSearchstringThe query every searchable column is matched against
onSearchChange(search: string) => voidFires when the query changes
searchablebooleanfalseDraws the search field above the table
searchPlaceholderstringThe field's placeholder and its accessible name
filter(row, index) => booleanA filter of your own, applied after the search. Return false to drop a row
toolbarReactNodeContent at the end of the bar the search field sits in
manualboolean | ('sort' | 'filter' | 'pages')[]falseWhich stages the caller has already done. true is all three
rowCountnumberHow many rows there are in total, when the table is not doing the paging
localestring'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

PropTypeDefaultDescription
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
editableboolean | ((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[]) => ReactNodeThe 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) => unknownWhat an export writes for this cell. Separate from render: a cell that draws a Chip has no text to put in a file
exportablebooleantrueLeaves this column out of an export
key * stringIdentifies the column, and unless value or render says otherwise, names the property to read off each row
labelReactNodekeyThe heading
groupstringAdjacent columns carrying the same string are drawn under one merged cell in a second header row
widthnumberHow wide, in pixels. Columns that do not say share whatever is left
minWidthnumber48How 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 / resizableboolean표의 값Overrides the table's own sortable and resizable for this column
searchablebooleantrueWhether the search looks in this column
hiddenbooleanLeaves the column out without removing it from the list
value(row) => unknownrow[key]The value behind the cell: what is sorted, and what the search is matched against
compare(a, b) => numberOrders two rows by this column when the default comparison cannot. Always written ascending; the table reverses it
render(row, index) => ReactNodeDraws 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:

Clickchooses that row and drops the rest
Ctrl/ + clickadds or removes one
Shift + clicktakes the run from the last row chosen
Click and dragtakes the run under the pointer, scrolling at the edges
move and choose
Home End PageUp PageDownscroll only: what is chosen stays chosen
Ctrl/ + arrowsmove without choosing
Shift + arrowsextend the run
Spacechoose the row the focus is on; with Ctrl/, toggle it
Ctrl/ + Aevery displayed row
Escnothing
Enter, double-clickonRowActivate

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.

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:

tsx
<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.

tsx
<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 selectionMode the table is a grid with one tab stop and aria-activedescendant, because a virtual row cannot hold the focus: the row that had it is unmounted the moment it scrolls away. Rows carry aria-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 carries aria-sort.
  • Give the table a caption or a label. 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 locale when 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.

Released under the MIT License