Table
A flat-props Table starting with columns + dataSource + rowKey, with built-in loading, empty, caption, function-form rowClassName, optional row selection, and single-column sorting. Built on the shadcn table primitive, with Base UI Checkbox used internally for selection so the header can show a distinct indeterminate state.
| Name | Role | |
|---|---|---|
| Ada Lovelace | ada@example.com | Owner |
| Linus Torvalds | linus@example.com | Admin |
| Grace Hopper | grace@example.com | Member |
Installation
With the @easy-shadcn namespace configured:
pnpm dlx shadcn@latest add @easy-shadcn/tableOr install via the full URL with the same namespace configured for its Compose dependency:
pnpm dlx shadcn@latest add https://easy-shadcn.vercel.app/r/table.jsonThe underlying shadcn primitives, Compose Pagination, Base UI Checkbox, and icon packages are installed automatically alongside table.
After shadcn add, import from @/components/easy/table. The examples below import from @/registry/ui/table for repo-internal reasons — substitute the install path in your app.
Table owns the root's generated caption/header/body/row structure, aria-busy state, and data-slot marker at both type and runtime seams. Caller-owned refs, accessible naming, roles, classes, styles, ordinary ARIA/data attributes, and native events remain available on the actual <table> element.
Usage
Basic
Define columns through the defineColumns<T>() builder (see below for why), then pass them to Table. rowKey is required — it derives a stable string key per row, either from a string / number field of T or from a (record, index) => string | number function.
import { defineColumns, Table } from "@/components/easy/table"
interface User {
id: string
name: string
email: string
}
const columns = defineColumns<User>()([
{ dataIndex: "name", key: "name", title: "Name" },
{ dataIndex: "email", key: "email", title: "Email" },
])
<Table columns={columns} dataSource={users} rowKey="id" />Column types — why defineColumns
TableColumn<T> is a discriminated union: when dataIndex is a literal keyof T, render(value, record, index) narrows value to T[dataIndex] (no casts). When dataIndex is omitted, value is undefined and you pull from record.
The catch: TypeScript's contextual typing for inline array literals (const cols: TableColumn<T>[] = [...]) can drop narrowing under certain strict-mode combinations — value falls back to any, and invalid dataIndex literals stop erroring. The defineColumns<T>() builder fixes this by binding the generic before inference and using const parameters to keep each element's dataIndex literal alive:
const cols = defineColumns<Order>()([
{
dataIndex: "amount",
key: "amount",
title: "Amount",
align: "right",
render: (value) => formatter.format(value), // value: number
},
{
dataIndex: "status",
key: "status",
title: "Status",
render: (value) => <Badge tone={value}>{value}</Badge>, // value: Order["status"]
},
{
key: "actions",
title: "Actions",
// No dataIndex → value is undefined.
render: (_value, record) => <Button onClick={() => view(record.id)}>View</Button>,
},
])You can still write const cols: TableColumn<T>[] = [...] directly if you don't need narrowing — dataIndex is still constrained to keyof T, render just has the union-of-shapes value type. Reach for defineColumns whenever you want IDE autocomplete to know what value is.
dataIndex is intentionally limited to keyof T — nested paths like "user.name" are not supported. Reach inside with render: (_, record) => record.user.name instead.
If a field is not directly renderable as ReactNode (for example Date or an object), the column must provide render. String, number, boolean, nullish, and ReactNode fields may omit render.
Formatting cells with column.render
column.render is the one render-style prop the project allows, because it lives at the data layer — formatting a value into a ReactNode — not the component layer. It receives (value, record, index) and lives inside each column definition above.
Click a row's View button
| Order | Customer | Amount | Status | Date | Actions |
|---|---|---|---|---|---|
| ord_001 | Acme Inc. | $1,299.00 | paid | 2026-05-12 | |
| ord_002 | Globex | $480.00 | pending | 2026-05-14 | |
| ord_003 | Initech | $75.00 | refunded | 2026-05-15 |
Loading, empty, and caption
loading={true} replaces the body with loadingMessage. When dataSource is empty and loading is false, emptyMessage is shown. caption renders inside <caption>.
| Task | Owner |
|---|---|
| Design table API | Ada |
| Ship docs | Linus |
Row highlighting via rowClassName
rowClassName accepts a ClassValue or a (record, index) => ClassValue function. Use the function form for conditional row styling without an extra wrapper.
| Server | Region | CPU |
|---|---|---|
| api-1 | us-east-1 | 32%(ok) |
| api-2 | us-east-1 | 91%(hot) |
| worker-1 | eu-west-1 | 12%(ok) |
| worker-2 | ap-south-1 | 88%(hot) |
<Table
columns={columns}
dataSource={servers}
rowKey="id"
rowClassName={(record) => record.cpu >= 80 ? "bg-destructive/10" : undefined}
/>Row selection
Set selectable to render a left-side checkbox column. The selection prop pair follows the same controlled / uncontrolled shape as other components in this library (value / onValueChange → selectedRowKeys / onSelectedRowKeysChange). The change callback receives both the new keys and the matching records in dataSource order, so you don't have to re-look-up rows. The header checkbox shows a distinct indeterminate (dash) icon while a non-empty proper subset is selected.
Per-row checkbox configuration goes through getCheckboxProps(record, index) — pass disabled: true or readOnly: true to gate a row out of header bulk selection. getCheckboxProps is the one xxxProps escape hatch in this library, kept for parity with Antd's Table API. Table fixes the checkbox element, indicator, role, selection state, derived ARIA/state markers, and slot marker at both type and runtime seams. Caller-owned form props, accessible naming/description, refs, state-aware classes/styles, safe events, and ordinary data attributes remain available. A normal onClick observes the event without replacing selection; Base UI's explicit event.preventBaseUIHandler() cancels its toggle.
Selected: none (the owner row is non-selectable)
| Selection | Name | Role |
|---|---|---|
| Ada Lovelace | owner | |
| Linus Torvalds | admin | |
| Grace Hopper | member | |
| Alan Turing | member |
const [selected, setSelected] = useState<string[]>([])
<Table
columns={columns}
dataSource={members}
rowKey="id"
selectable
selectedRowKeys={selected}
onSelectedRowKeysChange={(keys, rows) => {
setSelected(keys)
console.log("selected rows:", rows)
}}
getCheckboxProps={(record) => ({
disabled: record.role === "owner",
})}
/>Selection across pages / filters
Keys in selectedRowKeys that aren't present in the current dataSource are preserved — they survive pagination and filtering. onSelectedRowKeysChange's second argument (rows) only contains the records that actually exist in dataSource, so consumers usually do:
// `selected` lives in your parent state and accumulates across pages.
<Table
columns={columns}
dataSource={pageData}
rowKey="id"
selectable
selectedRowKeys={selected}
onSelectedRowKeysChange={setSelected}
/>For a header counter that reflects only the visible selection, derive it from your own visible set rather than selected.length:
const visibleSelected = selected.filter((k) => visibleIds.has(k));Pagination
Pass pagination to show ten rows per page, starting at page one. Omit it or pass false to render all rows without navigation. Configure pageSize, defaultValue, or controlled value / onValueChange only when needed.
Select this page, then move to another. Sorting keeps the current page.
| Selection | Project | |
|---|---|---|
| Project 01 | 23 | |
| Project 02 | 22 | |
| Project 03 | 21 | |
| Project 04 | 20 | |
| Project 05 | 19 | |
| Project 06 | 18 | |
| Project 07 | 17 | |
| Project 08 | 16 | |
| Project 09 | 15 | |
| Project 10 | 14 |
Selected: 0 projects
Local pagination derives its total from dataSource.length: resolve source identity, sort, then slice. Every index callback still receives the original source-array index. Header selection affects only eligible rows on the visible page, retaining off-page, disabled, read-only, and unavailable selected keys. Selection callbacks return matching records from the entire supplied dataSource in source order.
Sorting preserves the page. To reset it, control both sort and page and update them together in onSortChange. Values are one-based. Numeric inputs are truncated and capped at safe-integer bounds; non-finite page sizes fall back to ten, pages to one, and external totals to zero. Size is at least one, total at least zero, and the effective page is clamped to the available range (at least page one). Shrinking data or changing size silently persists that clamp for uncontrolled pages, so later growth cannot restore a discarded page. Controlled values only render-clamp and remain caller-owned. Prop changes never emit callbacks.
Controlled value overrides defaultValue; without a callback it is read-only. defaultValue is mount-only. Turning pagination off preserves the local preference; returning to local mode clamps it against current data. External mode never overwrites that preference. Loading disables page controls. Navigation is a sibling outside the table's scroll container; native table props and refs retain their target.
External pagination
Use mode: "external" with required total, value, and onValueChange when the caller supplies one page. Table never slices those records again, even if their count disagrees with total. The caller owns fetching, cancellation, stale responses, and fetching a valid page after totals shrink.
Simulated server page: the caller supplies only this page's records.
| Project |
|---|
| Project 1 |
| Project 2 |
| Project 3 |
| Project 4 |
| Project 5 |
Page 1. Supplied records: 5.
For server sorting, use sorter: true; an explicit local comparator still sorts only the supplied page. Selected keys can persist across requests, but callback records cannot include rows absent from the supplied page. total is forbidden in local mode; defaultValue is forbidden in external mode. Pagination configuration only accepts the documented fields, not arbitrary props or navigation hrefs.
Sorting
Set a column's sorter to a pure, non-throwing comparator to enable local sorting. Header buttons cycle ascending → descending → original order; choosing another column starts ascending. Equal comparisons retain source order in both directions. Comparators receive full records and handle your null, date, and locale rules. Sorting never mutates dataSource.
Sort a column: ascending, descending, then original order. Selection follows each project.
| Selection | ||
|---|---|---|
| Atlas | 24 | |
| Beacon | 8 | |
| Canvas | 24 |
Selected: none
Use defaultSort={{ columnKey: "tasks", order: "ascend" }} for an initial order. For controlled sorting, pass sort and onSortChange; null means no sorting, while undefined uses internal state. Controlled sort overrides defaultSort. The callback runs only for user requests. If the parent refuses a change, rows and header direction remain unchanged.
Sorting preserves source indices in rowKey, column.render, getCheckboxProps, rowClassName, and onRowClick. Selection callbacks still return matching records in dataSource order; bulk-selection keys also retain source order. Missing, non-sortable, duplicate-key, or invalid-direction sort configurations have no effective sort and emit no callback. Removing a sorted column suspends its order; restoring it restores the stored intent.
Externally ordered data
Set sorter: true for server sorting. Table changes the header state and emits intent but never reorders the supplied rows. The example deliberately keeps the same rows so this boundary is visible.
Intent-only example: the header changes, but supplied rows keep their order. In an app, use the request to fetch an ordered page.
| Project | |
|---|---|
| Atlas | 24 |
| Beacon | 8 |
| Canvas | 24 |
Requested order: none
Use controlled sort to connect the request to your query or URL state. The caller owns fetching, stale requests, error handling, and page resets. With server pagination or filtering, use sorter: true: a local comparator sorts only the records currently supplied to Table.
Sortable headers contain native buttons; Enter and Space activate them without submitting a surrounding form. Only the active header carries aria-sort. Arrows show direction independently of color, and loading disables the buttons. Sortable titles must contain no interactive elements. For an icon-only title, provide sortLabel with a meaningful button name.
Row click
Pass onRowClick(record, index) to make rows respond to interaction. The row keeps its native table-row semantics but becomes focusable (tabIndex=0) and reacts to mouse click, Enter, and Space. Clicks that originate inside the selection cell do not bubble to onRowClick, so checkboxes stay independent.
Interactive elements inside normal cells (Button, Link, input, etc.) are ignored by the row activation handler, so clicking or pressing Enter / Space on a cell button does not also fire onRowClick.
Click a row, or Tab + Enter / Space
| Selection | Name | Role | |
|---|---|---|---|
| Ada Lovelace | ada@example.com | owner | |
| Linus Torvalds | linus@example.com | admin | |
| Grace Hopper | grace@example.com | member | |
| Alan Turing | alan@example.com | member |
Try it: click a row body to set "viewing", then click a checkbox — the row click does not fire. Tab to a row and press Enter or Space to activate it.
<Table
aria-label="Members"
columns={columns}
dataSource={members}
rowKey="id"
onRowClick={(record) => router.push(`/members/${record.id}`)}
/>Accessibility
The Table ships with the WCAG defaults you'd expect, plus development-only warnings when they're missing:
-
Accessible name (WCAG 1.3.1) — pass
caption,aria-label, oraria-labelledby. Without one, dev builds log a warning. -
aria-busyis set on the<table>wheneverloadingistrue. -
Loading / empty cells keep their native
<td>semantics and contain an innerrole="status"+aria-live="polite"node so SR users hear the state change instead of waiting in silence. -
Selection column renders a visually-hidden
<span>carryingselectionColumnLabel(default"Selection") so the column has a real name for AT, even though the<th>shows only a checkbox. -
Selection checkboxes default to
aria-label="Select row {key}". The row key is usually opaque — pass a human label throughgetCheckboxProps:getCheckboxProps={(record) => ({ "aria-label": `Select ${record.name}` })} -
onRowClickpreserves native<tr>table semantics — we do NOT override torole="button"(that would strip the table structure). Rows gettabIndex={0}and respond to Enter / Space. Focus outline is inset (outline-offset: -2px) so a surroundingborderwon't clip it. For dense or highly interactive tables, prefer a real Link/Button in a cell. -
Color is never the only signal in row highlighting — pair
rowClassNamewith an icon or text cue (see the row-className example).
API
TableProps<T>
| Prop | Type | Default | Description |
|---|---|---|---|
columns | TableColumn<T>[] | — | Column definitions. Use defineColumns<T>() for narrowed render(value, …) types. |
dataSource | T[] | null | undefined | — | Data rows. null / undefined are treated as empty, which matches SWR / React Query pre-response states. |
rowKey | string / number field of T | (record, index) => string | number | — | Required. Derives a stable string key per row. No index fallback — reordering / pagination would silently desync React keys. |
pagination | boolean | TablePaginationLocal | TablePaginationExternal | false | Local or external page membership and controls. See configuration below. |
loading | boolean | false | Replaces the body with loadingMessage. |
loadingMessage | ReactNode | "Loading…" | Shown while loading is true. |
emptyMessage | ReactNode | "No data" | Shown when dataSource is empty and not loading. |
caption | ReactNode | — | Rendered inside <caption>. |
rowClassName | ClassValue | (record, index) => ClassValue | — | Per-row className. |
onRowClick | (record, index) => void | — | Makes rows focusable (tabIndex=0) and responsive to click / Enter / Space. Native role="row" is preserved (no override). Selection-cell events do not bubble. |
selectable | boolean | false | Enable the selection column. |
selectedRowKeys | string[] | — | Controlled selected keys. |
defaultSelectedRowKeys | string[] | [] | Uncontrolled initial keys. |
sort | TableSort | null | — | Controlled single-column sort. null clears; undefined uses internal state. |
defaultSort | TableSort | null | null | Initial uncontrolled sort; ignored when sort is defined. |
onSortChange | (sort: TableSort | null) => void | — | User-requested order. TableSort is { columnKey: string; order: "ascend" | "descend" }. |
onSelectedRowKeysChange | (keys, rows) => void | — | Called with the next keys and the matching records. |
getCheckboxProps | (record, index) => Partial<TableCheckboxProps> | — | Per-row caller-owned Checkbox props (for example disabled, readOnly, aria-label, form props, refs, safe events, and ordinary data-*; see Base UI Checkbox). Table-owned element, state, derived ARIA/data, and indicator props are excluded. disabled and readOnly gate the row out of header bulk selection. |
selectionColumnClassName | ClassValue | — | className for the selection column's th and td. |
selectionColumnLabel | string | "Selection" | Visually-hidden column name for the selection <th> (announced before "Select all" by screen readers). |
headerClassName | ClassValue | — | className on <thead>. |
bodyClassName | ClassValue | — | className on <tbody>. |
captionClassName | ClassValue | — | className on <caption>. |
emptyClassName | ClassValue | — | className on the empty-state cell. |
loadingClassName | ClassValue | — | className on the loading-state cell. |
className | ClassValue | — | className on the <table> element. Other caller-owned native table props and refs are forwarded; generated children, raw HTML, aria-busy, and the root slot marker are fixed by Table. |
TablePagination
Exported as boolean | TablePaginationLocal | TablePaginationExternal.
| Field | Default | Contract |
|---|---|---|
mode | "local" | "external" requires controlled page, callback, and total. |
pageSize | 10 | Rows per local page, or the server's requested page size. |
value | — | Controlled one-based page; local mode permits read-only use. |
defaultValue | 1 | Local initial page only; ignored when value is defined. |
onValueChange | — | (value: number) => void; changed user page requests only. Required externally. |
total | — | Required externally; forbidden locally, where it derives from supplied rows. |
hideOnSinglePage | false | Hide navigation when there is at most one page; does not hide the table. |
aria-label | "Table pagination" | Navigation landmark name. Use distinct labels for multiple tables. |
className | — | ClassValue applied to the navigation root, outside the table. |
TableColumn<T>
A discriminated union by dataIndex. When dataIndex is set to a keyof T, render's value is narrowed to T[dataIndex] automatically — no casts needed.
| Field | Type | Description |
|---|---|---|
key | string | Stable identifier and React key. |
title | ReactNode | Header content. |
sorter | true | ((a: T, b: T) => number) | Comparator enables local sorting; true emits external sort intent without changing row order. Comparators must be pure and non-throwing. |
sortLabel | string | Accessible sort-button name for titles without meaningful text. Sortable titles must not contain interactive descendants. |
dataIndex | keyof T | undefined | Top-level keyof T only. Nested paths like "user.name" are intentionally unsupported — use render: (_, record) => record.user.name for deep access. |
render | (value, record, index) => ReactNode | Cell formatter. value is T[dataIndex] when dataIndex is set, otherwise undefined. Required when dataIndex points to a non-renderable field such as Date or an object. |
align | "left" | "center" | "right" | Text alignment for th + td. |
width | number | string | Emitted as inline style.width. |
className | ClassValue | Applied to both th and td. |
headClassName | ClassValue | Applied only to the header cell. |
cellClassName | ClassValue | Applied only to body cells. |
Recipe: truncating long cells
Single-line truncation needs no extra props — combine a fixed table layout, a column width, and truncate (the primitive <td> already applies whitespace-nowrap). With table-fixed + width the column width is fixed by the header; add max-w-0 only if you stay on the default auto layout, where the cell would otherwise grow with its content:
<Table
className="table-fixed"
columns={[
{
cellClassName: "truncate max-w-0",
dataIndex: "description",
key: "description",
title: "Description",
width: 240,
},
// ...
]}
dataSource={rows}
rowKey="id"
/>When to use the primitive instead
This component covers columns + dataSource + rowKey, selection, single-column sorting, local/external pagination, and loading / empty / caption / row className. More complex data operations remain outside this delivery:
- Multi-column sorting and filtering — derive these in your parent and feed
dataSource. Usesorter: truefor externally ordered data andpagination.mode: "external"for supplied pages. Combine with@tanstack/react-tablefor broader data operations. - Page-size selectors, quick jumpers, and URL navigation — compose caller-owned controls and state; Table's integrated pager exposes only page changes.
- Fixed columns / sticky headers, expandable rows, drag-to-reorder, column resize, virtualization — composition territory.
- Error state — the Table has
loadingandemptyMessagebut noerrorMessage. Rows aren't symmetric to a single async load, so the error UX is the parent's responsibility. Render your own error block above the Table (or swap the Table for an error block) when fetching fails. - Per-row loading (one row showing a saving spinner while others stay live) — render the spinner inside a cell via
render. Table-wideloadingis all-or-nothing.
onRowClick puts every row in the Tab sequence; that's fine up to 20–30 interactive rows. For larger interactive tables you want a roving-tabindex grid pattern, which is also out of scope — reach for @tanstack/react-table and compose the shadcn primitives directly.
For any of the above, drop down to components/ui/table and compose <Table>, <TableHeader>, <TableBody>, <TableRow>, <TableHead>, <TableCell>, <TableCaption> yourself.
Server components
This component is "use client" — selection state, focus management and dev warnings all need the client runtime. A purely static read-only table (just columns + dataSource + rowKey + caption + a string rowClassName) doesn't strictly need that, but the Table forces CSR anyway. In an RSC page, either accept the client boundary, or drop down to components/ui/table primitives directly for the static case.