Table
Paginated data table backed by `@tanstack/react-table`'s row models. For large row counts, use `VirtualTable` instead — windowing and pagination are two strategies for the same problem, so neither component combines them.
import { Table } from '@elirobinson/react/components/organisms/Table';Styles: @elirobinson/react/styles/organisms/Table.css@elirobinson/react/styles/organisms/table/core.css — already included when you import @elirobinson/react/styles.css.
Columns sort by clicking their header — that's on by default for every column with an
accessorKey, no extra prop required. Pagination appears automatically once the row count
passes pageSize.
| INV-1000 | Jordan Ellis | Youth Football Fundamentals | $18.00 | 2026-01-01 |
| INV-1001 | Priya Nair | Basketball Conditioning | $24.00 | 2026-02-02 |
| INV-1002 | Sam Okafor | Rugby Contact Basics | $30.00 | 2026-03-03 |
| INV-1003 | Maria Gonzalez | Athletics Sprint Mechanics | $36.00 | 2026-04-04 |
| INV-1004 | Tomás Rivera | Netball Footwork Drills | $42.00 | 2026-05-05 |
| INV-1005 | Aisha Bello | Swim Stroke Technique | $18.00 | 2026-06-06 |
| INV-1006 | Liam Chen | Youth Football Fundamentals | $24.00 | 2026-01-07 |
| INV-1007 | Grace Kim | Basketball Conditioning | $30.00 | 2026-02-08 |
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/Table';
import { Table } from '@elirobinson/react/components/organisms/Table';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
purchasedOn: string;
};
const CUSTOMERS = [
'Jordan Ellis',
'Priya Nair',
'Sam Okafor',
'Maria Gonzalez',
'Tomás Rivera',
'Aisha Bello',
'Liam Chen',
'Grace Kim',
];
const GUIDES = [
'Youth Football Fundamentals',
'Basketball Conditioning',
'Rugby Contact Basics',
'Athletics Sprint Mechanics',
'Netball Footwork Drills',
'Swim Stroke Technique',
];
function makeOrders(count: number): Order[] {
return Array.from({ length: count }, (_, index) => ({
id: `INV-${1000 + index}`,
customer: CUSTOMERS[index % CUSTOMERS.length],
guide: GUIDES[index % GUIDES.length],
amount: `$${(18 + (index % 5) * 6).toFixed(2)}`,
purchasedOn: `2026-0${(index % 6) + 1}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
}
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
{ accessorKey: 'purchasedOn', header: 'Purchased' },
];
const orders = makeOrders(24);
export default function Basic() {
return <Table data={orders} columns={columns} pageSize={8} />;
}When to use it
Use Table when the data fits in pages — a results list, an order history, anything a
reader might reasonably want to jump to "page 3 of 6" on. It's built on
@tanstack/react-table's real row models, so sorting, filtering, and pagination all read
from the same computed row set instead of three separate, driftable pieces of state.
For a few hundred rows or more where paging feels like busywork, reach for VirtualTable
instead. Windowing and pagination solve the same problem — too many rows to put in the DOM
at once — so Table deliberately doesn't try to do both.
Filtering
filterable adds a global text filter above the table, wired to TanStack's
getFilteredRowModel(). filterPlaceholder becomes both the placeholder text and the
input's accessible name.
| INV-1000 | Jordan Ellis | Youth Football Fundamentals | $18.00 | 2026-01-01 |
| INV-1001 | Priya Nair | Basketball Conditioning | $24.00 | 2026-02-02 |
| INV-1002 | Sam Okafor | Rugby Contact Basics | $30.00 | 2026-03-03 |
| INV-1003 | Maria Gonzalez | Athletics Sprint Mechanics | $36.00 | 2026-04-04 |
| INV-1004 | Tomás Rivera | Netball Footwork Drills | $42.00 | 2026-05-05 |
| INV-1005 | Aisha Bello | Swim Stroke Technique | $18.00 | 2026-06-06 |
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/Table';
import { Table } from '@elirobinson/react/components/organisms/Table';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
purchasedOn: string;
};
const CUSTOMERS = [
'Jordan Ellis',
'Priya Nair',
'Sam Okafor',
'Maria Gonzalez',
'Tomás Rivera',
'Aisha Bello',
'Liam Chen',
'Grace Kim',
];
const GUIDES = [
'Youth Football Fundamentals',
'Basketball Conditioning',
'Rugby Contact Basics',
'Athletics Sprint Mechanics',
'Netball Footwork Drills',
'Swim Stroke Technique',
];
function makeOrders(count: number): Order[] {
return Array.from({ length: count }, (_, index) => ({
id: `INV-${1000 + index}`,
customer: CUSTOMERS[index % CUSTOMERS.length],
guide: GUIDES[index % GUIDES.length],
amount: `$${(18 + (index % 5) * 6).toFixed(2)}`,
purchasedOn: `2026-0${(index % 6) + 1}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
}
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
{ accessorKey: 'purchasedOn', header: 'Purchased' },
];
const orders = makeOrders(24);
export default function Filterable() {
return (
<Table
data={orders}
columns={columns}
pageSize={6}
filterable
filterPlaceholder="Filter orders"
/>
);
}Empty state
emptyMessage renders through EmptyState in place of the row set — this fires both for
genuinely empty data and for a filter that matches nothing.
No orders yet | |||
Show code
import type { ColumnDef } from '@elirobinson/react/components/organisms/Table';
import { Table } from '@elirobinson/react/components/organisms/Table';
type Order = {
id: string;
customer: string;
guide: string;
amount: string;
};
const columns: ColumnDef<Order>[] = [
{ accessorKey: 'id', header: 'Invoice' },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'guide', header: 'Guide' },
{ accessorKey: 'amount', header: 'Amount' },
];
export default function Empty() {
return <Table data={[]} columns={columns} emptyMessage="No orders yet" />;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | ColumnDef<T>[] | — | — |
datarequired | T[] | — | — |
emptyMessage | string | — | Title shown by the EmptyState row when there are no rows to display. |
filterable | boolean | — | Shows a built-in global filter input above the table, wired to TanStack's `getFilteredRowModel()`. |
filterPlaceholder | string | — | Accessible label / placeholder for the built-in filter input. |
pageSize | number | — | Rows per page. |
Accessibility
- Sortable column headers render as a native
<button>inside the<th>, and the<th>itself carriesaria-sort(ascending/descending/none) reflecting the live sort state. - The filter input is
type="search"witharia-labelset fromfilterPlaceholder. - The pagination footer — only present once there's more than one page — is a
navlandmark labeled "Pagination", withPrevious page/Next pagebuttons that disable themselves at the edges andaria-current="page"on the active page number. - Keyboard: everything is a real
<button>or<input>, soTabreaches sort headers, the filter field, and pagination controls in document order, andEnter/Spaceactivates a focused one. None of it is reimplemented. - The empty state renders as a single cell with
colSpanacross every visible column, rather than a row of empty cells — a screen reader hits one coherent message, not a row it has to parse as data.
Do
- Give every column a stable accessorKey so sorting and cell rendering read from the exact same field.
- Set filterPlaceholder to something specific ("Filter orders") — it doubles as the input's accessible name.
- Write an emptyMessage for the actual context ("No orders yet") instead of leaving the generic default.
- Size pageSize to the data's natural chunk — a week of activity, a page of search results.
Don't
- Reach for Table once a dataset runs past a few hundred rows with no natural stopping point — use VirtualTable instead.
- Assume data.length tells you whether the table is empty — after filtering, the visible row model can be empty while data still has rows.
- Pair filterable with a dataset large enough that typing feels laggy — that is VirtualTable or a server-side filter's job.
- Fight the default column sorting with CSS — turn it off per column with enableSorting: false in the ColumnDef instead.