Skip to content

Table

Renders a grid of data from a column definition and a row list. There are no <tr>s or <td>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
emptyReactNodeWhat 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
localestringBCP 47 tag deciding the strings it draws on its own behalf, and the locale the default sort compares strings in
classNamesNebaSlots<'table' | 'caption' | 'head' | 'headCell' | 'body' | 'row' | 'cell' | 'empty'>Class names for the parts behind the root. The root itself is className, so there is no root key

The outer sheet is a Box: variant · size · color · density · elevation all pass straight through.

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;
}

With render you draw the cell yourself; without it, row[key] is printed as-is.

Examples

width and align

width is a starting width. The table still balances its columns to fill the available space, so it acts as a proportion rather than a fixed value. It is written onto a <col>, so it applies consistently to every row.

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

striped · hoverable · onRowClick

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

getRowKey defaults to the row index. Pass it whenever sorting or filtering can reorder the rows.

empty

What to show when items is empty. It renders as a single cell spanning every column.

Pins the header row while the body scrolls. It only does anything if something around the table constrains its height.

classNames

className is the sheet (the Box the table scrolls horizontally inside), so the <table> and everything in it are reached through classNames.

tsx
<Table
  headers={headers}
  items={items}
  classNames={{ table: 'tabular-nums', headCell: 'text-(--neba-fg)', row: 'align-top' }}
/>

The slots are table, caption, head, headCell, body, row, cell and empty.

One thing to know before reaching for cell: a cell's padding, alignment and background are written as inline styles rather than as utilities, because a host stylesheet's td rule outranks any one-class utility. A class you hand to headCell, cell or empty can add anything the component does not already set inline (a colour, a font, a border), but changing one of those three needs an important utility (p-4!). See prop conventions.

Accessibility

  • Renders a real <table> with <th scope="col"> headings.
  • A caption is read as the table's accessible name.
  • The empty state cell uses colSpan to cover every column, so it is not announced as short text in the first column.
  • With onRowClick, a row enters the tab order, answers Enter and Space, and draws a focus-visible ring. Its role is left alone, so the column headings and the row's position are still announced.
  • A link or a button inside a cell keeps its own keys. The click it raises still bubbles to the row, so call event.stopPropagation() in that control's handler if the row should not open with it.
  • locale decides what an empty table says; empty writes it out instead.

Released under the MIT License