Skip to content

Table

A grid of data, rendered from a column list and a row list. There are no <tr>s to write.

tsx
import { Table, type TableColumn } from 'neba';

const headers: TableColumn<Deploy>[] = [
  { key: 'environment', label: 'Environment', width: 180 },
  { key: 'duration', label: 'Duration', align: 'end', render: (row) => `${row.duration}m` }
];

<Table headers={headers} items={deploys} getRowKey={(row) => row.id} />;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface, passed straight to the Box. `text` leaves the rows with no sheet
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Type scale and padding of the cells, and the radius of the sheet
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. The sheet is white, so it reaches the hairline and the rules between rows
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
headers * readonly TableColumn[]The columns: key, label, width, align and render
items * readonly Row[]The rows
getRowKey(row, index) => KeyindexA stable key per row. Required the moment the table sorts or filters
captionReactNodeShown above the table, and read as its accessible name
emptyReactNode'No data'What to show instead of rows when items is empty
stripedbooleanfalseTints every other row. For a wide table the eye has to track across
hoverablebooleanfalseLights the row under the pointer
stickyHeaderbooleanfalsePins the header while the body scrolls. Only does anything if the height is constrained
onRowClick(row, index) => voidMakes the rows activatable

The sheet is a Boxvariant, size, color, density and elevation all pass straight through, so a table is styled on the same axes as everything it might sit next to. What Table adds is the part that is genuinely tabular.

TableColumn

ts
interface TableColumn<Row> {
  key: string; // identifies the column, and names the property to read
  label?: React.ReactNode; // the heading; defaults to the key
  width?: number | string; // a number is pixels, a string is any CSS length
  align?: 'start' | 'center' | 'end';
  render?: (row: Row, index: number) => React.ReactNode;
}

This is the whole reason Table takes data rather than markup. A <td> written out per row can silently disagree with the <th> above it about how many there are or what order they come in; a column list cannot.

Examples

Widths and alignment

width is a default: the table still balances its columns to fill the available space, so this is a starting proportion rather than a guarantee. It is written onto a <col> rather than onto the first row's cells — a width set on a <th> is a width the browser renegotiates against every other row, and only the column element states it once.

Numbers usually want align: 'end' so their digits line up.

Rows

striped is for a wide table where the eye has to track across; on a narrow one it is noise. onRowClick makes the rows activatable and turns the hover treatment on with it.

getRowKey defaults to the row's index. That is fine for a static table and wrong for one that sorts or filters — pass it the moment the rows can move.

Empty

The header row

The header sits one step up the sheet's opacity ladder rather than taking a tint: it is still the container, and a coloured band behind a row of column names is the fastest way to make data look like chrome. The rule under it is the same --n-line a Card scores its sections with.

stickyHeader only does anything if something around the table actually constrains its height.

Accessibility

  • Renders a real <table> with <th scope="col"> headings.
  • A caption is read as the table's accessible name.
  • The empty state spans every column, so it is announced as one cell rather than as a short first column.

Released under the MIT License