Data Table
A sortable, optionally filterable and paginated table driven by a columns/data pair. Built on Table, Pagination, and Input.
| Ada Lovelace | ada@example.com | Engineer | 98 |
| Grace Hopper | grace@example.com | Engineer | 95 |
| Alan Turing | alan@example.com | Researcher | 99 |
| Katherine Johnson | katherine@example.com | Mathematician | 97 |
| Margaret Hamilton | margaret@example.com | Engineer | 96 |
Usage#
import { DataTable, type DataTableColumn } from "neelam-ui";
interface Person {
id: number;
name: string;
role: string;
score: number;
}
const columns: DataTableColumn<Person>[] = [
{ key: "name", header: "Name", sortable: true },
{ key: "role", header: "Role", sortable: true },
{ key: "score", header: "Score", sortable: true, align: "right" },
];
<DataTable columns={columns} data={people} getRowId={(row) => row.id} />Order of operations#
Filtering runs before sorting and pagination. That ordering is what makes the page count and sort order describe the rows actually on screen, rather than the unfiltered set — a common off-by-a-page bug when the three are composed in the wrong order.
Columns#
key must be a key of your row type, so a typo is a type error rather than a
column of undefined.
Custom cells#
cell takes over rendering for a column:
{
key: "role",
header: "Role",
sortable: true,
cell: (row) => <Badge variant="secondary">{row.role}</Badge>,
filterValue: (row) => row.role,
}Custom cells need filterValue
Once cell returns an element rather than a string, the filter has no text to
match against and that column silently stops being searchable. filterValue
gives it the raw string back. The same applies to sortValue for columns
whose displayed value is not itself sortable — a formatted date, for example.
Sorting a formatted value#
{
key: "createdAt",
header: "Created",
sortable: true,
cell: (row) => formatDate(row.createdAt),
sortValue: (row) => row.createdAt.getTime(),
}Without sortValue, "1 April" sorts before "1 March" — alphabetically correct
and chronologically wrong.
Filtering#
filterable adds a search box above the table that matches across every
column. Match counts are announced through a live region, so a screen reader
user learns that the result set changed without having to go looking.
<DataTable
columns={columns}
data={people}
filterable
filterLabel="Filter people"
noMatchesMessage="No people match that search."
/>Pagination#
Set pageSize to paginate; omit it to render every row. To place the pagination
controls somewhere else — outside a card the table sits inside, say — combine
hidePagination with onPaginationChange and render your own, while
DataTable keeps owning the sort, filter, and page maths:
const [pagination, setPagination] = useState(null);
<DataTable
columns={columns}
data={people}
pageSize={10}
hidePagination
onPaginationChange={setPagination}
/>;
{pagination && (
<Pagination
page={pagination.page}
totalPages={pagination.totalPages}
onPageChange={pagination.setPage}
/>
)}hidePagination has no effect without pageSize.
Keyboard#
| Key | Behaviour |
|---|---|
| Tab | Reaches the filter box, each sortable column header, and the pagination controls in visual order. |
| EnterSpace | On a sortable column header, cycles ascending → descending → unsorted. |
Accessibility#
- Renders a real
<table>with<th scope="col">headers, so table navigation commands work in screen readers. - Sortable headers are
<button>s carryingaria-sort, which is updated as the sort cycles — the current sort is announced, not merely drawn as an arrow. - Filter match counts are announced through a live region.
- The empty state distinguishes "no data at all" (
emptyMessage) from "your filter excluded everything" (noMatchesMessage), which are very different situations for the user.
Deliberate omissions#
Row selection, column resizing, column reordering, virtualisation, and
server-side data are not included. Each would push the component from a
presentation concern into a state-management one, and the reasoning is recorded
in DECISIONS.md in the repository. For those cases, compose
Table with your own logic.
API reference#
DataTable#
| Prop | Type | Default |
|---|---|---|
columnsrequired | DataTableColumn<T>[] | — |
datarequired | T[] | — |
emptyMessage | ReactNode | No results. |
filterableShows a search box above the table that filters rows across every column. | boolean | false |
filterLabelThe search box's accessible name. Defaults to `"Filter rows"`. | string | Filter rows |
filterPlaceholder | string | Search… |
getRowIdIdentifies each row for React's `key` — defaults to its index, which is fine unless rows are reordered by sorting across a paginated boundary in a way that would matter for e.g. focus/animation state, which this component doesn't have anyway. | ((row: T, index: number) => string | number) | — |
hidePaginationSuppresses the built-in pagination footer while `pageSize` still pages the rows internally exactly as before — pair with `onPaginationChange` to render an equivalent footer somewhere else, e.g. outside a card DataTable itself renders inside. Has no effect without `pageSize`. | boolean | false |
noMatchesMessageShown in place of `emptyMessage` when a filter is what emptied the table. | ReactNode | No rows match your filter. |
onPaginationChangeReports the current page, total page count, and a setter, whenever any of them changes — a page turn, or the row count changing under a filter or a new `data` prop. DataTable still owns the state either way; this just also hands it outward, which only matters paired with `hidePagination`. | ((state: DataTablePaginationState) => void) | — |
pageSizeRows per page. Omit to disable pagination and render every row. | number | — |
DataTableColumn#
| Prop | Type | Default |
|---|---|---|
headerrequired | ReactNode | — |
keyrequiredMust be a key of `T` — read as the cell's value unless `cell` is given, and used as this column's React key. | keyof T & string | — |
align | Align | — |
cellCustom cell content. Defaults to `String(row[key])`. | ((row: T) => ReactNode) | — |
className | string | — |
filterValueThe text this column contributes to the filter. Defaults to `String(row[key])` — set it for columns whose `cell` renders something the raw value doesn't describe (an avatar, a status badge), or pass `() => ""` to exclude the column from filtering entirely. | ((row: T) => string) | — |
sortableEnables click-to-sort on this column's header. | boolean | — |
sortValueCustom sort key, for columns whose displayed value isn't itself sortable (e.g. a formatted date). Defaults to `row[key]`. | ((row: T) => string | number) | — |