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.

organismsSource
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-1000Jordan EllisYouth Football Fundamentals$18.002026-01-01
INV-1001Priya NairBasketball Conditioning$24.002026-02-02
INV-1002Sam OkaforRugby Contact Basics$30.002026-03-03
INV-1003Maria GonzalezAthletics Sprint Mechanics$36.002026-04-04
INV-1004Tomás RiveraNetball Footwork Drills$42.002026-05-05
INV-1005Aisha BelloSwim Stroke Technique$18.002026-06-06
INV-1006Liam ChenYouth Football Fundamentals$24.002026-01-07
INV-1007Grace KimBasketball Conditioning$30.002026-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-1000Jordan EllisYouth Football Fundamentals$18.002026-01-01
INV-1001Priya NairBasketball Conditioning$24.002026-02-02
INV-1002Sam OkaforRugby Contact Basics$30.002026-03-03
INV-1003Maria GonzalezAthletics Sprint Mechanics$36.002026-04-04
INV-1004Tomás RiveraNetball Footwork Drills$42.002026-05-05
INV-1005Aisha BelloSwim Stroke Technique$18.002026-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

PropTypeDefaultDescription
columnsrequiredColumnDef<T>[]
datarequiredT[]
emptyMessagestringTitle shown by the EmptyState row when there are no rows to display.
filterablebooleanShows a built-in global filter input above the table, wired to TanStack's `getFilteredRowModel()`.
filterPlaceholderstringAccessible label / placeholder for the built-in filter input.
pageSizenumberRows per page.

Accessibility

  • Sortable column headers render as a native <button> inside the <th>, and the <th> itself carries aria-sort (ascending / descending / none) reflecting the live sort state.
  • The filter input is type="search" with aria-label set from filterPlaceholder.
  • The pagination footer — only present once there's more than one page — is a nav landmark labeled "Pagination", with Previous page / Next page buttons that disable themselves at the edges and aria-current="page" on the active page number.
  • Keyboard: everything is a real <button> or <input>, so Tab reaches sort headers, the filter field, and pagination controls in document order, and Enter/Space activates a focused one. None of it is reimplemented.
  • The empty state renders as a single cell with colSpan across 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.