Easy Shadcn
Components

Table

A shadcn/ui data Table from columns, dataSource, and rowKey, with loading, empty state, row selection, and sorting.

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.

NameEmailRole
Ada Lovelaceada@example.comOwner
Linus Torvaldslinus@example.comAdmin
Grace Hoppergrace@example.comMember

Installation

With the @easy-shadcn namespace configured:

pnpm dlx shadcn@latest add @easy-shadcn/table

Or 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.json

The 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

OrderCustomerAmountStatusDateActions
ord_001Acme Inc.$1,299.00paid2026-05-12
ord_002Globex$480.00pending2026-05-14
ord_003Initech$75.00refunded2026-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>.

Sprint backlog
TaskOwner
Design table APIAda
Ship docsLinus

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.

ServerRegionCPU
api-1us-east-132%(ok)
api-2us-east-191%(hot)
worker-1eu-west-112%(ok)
worker-2ap-south-188%(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)

SelectionNameRole
Ada Lovelaceowner
Linus Torvaldsadmin
Grace Hoppermember
Alan Turingmember
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.

SelectionProject
Project 0123
Project 0222
Project 0321
Project 0420
Project 0519
Project 0618
Project 0717
Project 0816
Project 0915
Project 1014

Selected: 0 projects

Local pagination derives its total from the filtered supplied rows: resolve source identity, filter, 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.

Column filtering

Add filter to a column to show a filter button beside its title. The smallest local configuration supplies items and a pure, non-throwing onFilter(value, record) predicate. Filter option values are strings; titles and option labels must contain meaningful, non-interactive content.

Filter by role or team, then sort by tasks. Selected rows stay selected when hidden.

Team members
SelectionNameRoleTeam
AdaadminProduct12
BenmemberSupport8
CatadminSupport5
{
  key: "role",
  dataIndex: "role",
  title: "Role",
  filter: {
    items: [
      { value: "admin", label: "Admin" },
      { value: "member", label: "Member" },
    ],
    onFilter: (value, record) => record.role === value,
  },
}

Checkbox changes are drafts until Apply. Escape or clicking outside discards them. Reset immediately clears that column, preserving other columns. Use multiple: false for a radio choice. Selected options within one column combine with OR; active columns combine with AND. The button shows the accepted option count, not the draft count. Loading disables the trigger, choices, Apply, and Reset, including an already-open panel.

Use defaultFilters={{ role: ["admin"] }} for initial internal state. For controlled filtering, pass filters and onFiltersChange; {} clears all, while undefined uses internal state. Controlled values override defaults. If the parent refuses a request, rows, counts, and the values on reopening stay unchanged. A change to accepted values or option identities, disabled state, mode, or multiplicity discards an open stale draft.

Local processing resolves original row keys and indices, filters, sorts, then slices pages. The local total is the filtered row count. Empty results use emptyMessage. Every existing index callback keeps the source-array index. Selected keys survive hiding rows; select-all changes only eligible rows on the visible page. Selection callback records still come from the complete supplied dataSource in source order.

An effective filter change returns enabled, uncontrolled local pagination to page one. It preserves sorting and selection. Controlled pages remain caller-owned, subject to the existing render clamp. Accept the suggested page and filters together:

onFiltersChange={(filters, details) => {
  setQuery((query) => ({
    ...query,
    filters,
    page: details.pagination?.value ?? query.page,
  }))
}}

Only changed user commits emit onFiltersChange(next, details). details.columnKey identifies the edited column; details.pagination is { value: 1 } when pagination is enabled and null otherwise. No second pagination callback fires. Mounts, prop/data/config changes, cancelled drafts, and unchanged commits emit nothing. Changing data keeps the existing silent page-clamp behavior.

Unknown columns or option values and duplicate column/option identities have no effective filter. Values are deduplicated and ordered by options; single mode uses the first valid option in that order. Stored intent is retained when a column or option disappears and restored if it returns. Callbacks retain inactive intent for other columns. disabled prevents user choice of an option; an already supplied disabled value can remain active until Reset clears it.

External filtering

Set filter.mode: "external" and omit onFilter. Table updates the filter interface and emits intent without removing supplied rows. Control filters to connect them to a query or URL. The application owns fetching, cancellation, stale responses, errors, and filtered totals.

Intent preview: the supplied rows stay unchanged. Your data layer fetches the requested result.

Server-filtered members
NameRole
Adaadmin
Benmember
{
  "filters": {},
  "page": 2
}

Use external filters with external pagination for server queries. An explicitly local predicate combined with external pagination filters only the supplied page and does not revise the caller's total. Filter controls and sort controls are independent siblings; using one does not activate the other or submit a surrounding form.

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
Atlas24
Beacon8
Canvas24

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
Atlas24
Beacon8
Canvas24

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

SelectionNameEmailRole
Ada Lovelaceada@example.comowner
Linus Torvaldslinus@example.comadmin
Grace Hoppergrace@example.commember
Alan Turingalan@example.commember

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, or aria-labelledby. Without one, dev builds log a warning.

  • aria-busy is set on the <table> whenever loading is true.

  • Loading / empty cells keep their native <td> semantics and contain an inner role="status" + aria-live="polite" node so SR users hear the state change instead of waiting in silence.

  • Selection column renders a visually-hidden <span> carrying selectionColumnLabel (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 through getCheckboxProps:

    getCheckboxProps={(record) => ({ "aria-label": `Select ${record.name}` })}
  • onRowClick preserves native <tr> table semantics — we do NOT override to role="button" (that would strip the table structure). Rows get tabIndex={0} and respond to Enter / Space. Focus outline is inset (outline-offset: -2px) so a surrounding border won'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 rowClassName with an icon or text cue (see the row-className example).

API

TableProps<T>

PropTypeDefaultDescription
columnsTableColumn<T>[]—Column definitions. Use defineColumns<T>() for narrowed render(value, …) types.
dataSourceT[] | null | undefined—Data rows. null / undefined are treated as empty, which matches SWR / React Query pre-response states.
rowKeystring / 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.
paginationboolean | TablePaginationLocal | TablePaginationExternalfalseLocal or external page membership and controls. See configuration below.
loadingbooleanfalseReplaces the body with loadingMessage.
loadingMessageReactNode"Loading…"Shown while loading is true.
emptyMessageReactNode"No data"Shown when dataSource is empty and not loading.
captionReactNode—Rendered inside <caption>.
rowClassNameClassValue | (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.
selectablebooleanfalseEnable the selection column.
selectedRowKeysstring[]—Controlled selected keys.
filtersTableFilters—Controlled values by column key: Record<string, string[]>. {} clears all.
defaultFiltersTableFilters{}Mount-only internal defaults; ignored when controlled.
onFiltersChange(filters, details) => void—Changed user intent; details contains columnKey and suggested pagination { value: 1 } or null.
defaultSelectedRowKeysstring[][]Uncontrolled initial keys.
sortTableSort | null—Controlled single-column sort. null clears; undefined uses internal state.
defaultSortTableSort | nullnullInitial 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.
selectionColumnClassNameClassValue—className for the selection column's th and td.
selectionColumnLabelstring"Selection"Visually-hidden column name for the selection <th> (announced before "Select all" by screen readers).
headerClassNameClassValue—className on <thead>.
bodyClassNameClassValue—className on <tbody>.
captionClassNameClassValue—className on <caption>.
emptyClassNameClassValue—className on the empty-state cell.
loadingClassNameClassValue—className on the loading-state cell.
classNameClassValue—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.

FieldDefaultContract
mode"local""external" requires controlled page, callback, and total.
pageSize10Rows per local page, or the server's requested page size.
value—Controlled one-based page; local mode permits read-only use.
defaultValue1Local 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.
hideOnSinglePagefalseHide 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.

FieldTypeDescription
keystringStable identifier and React key.
titleReactNodeHeader content.
filterTableColumnFilter<T>Finite option filtering; local mode requires onFilter, external mode forbids it. See below.
sortertrue | ((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.
sortLabelstringAccessible sort-button name for titles without meaningful text. Sortable titles must not contain interactive descendants.
dataIndexkeyof T | undefinedTop-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) => ReactNodeCell 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.
widthnumber | stringEmitted as inline style.width.
classNameClassValueApplied to both th and td.
headClassNameClassValueApplied only to the header cell.
cellClassNameClassValueApplied only to body cells.

TableColumnFilter<T>

FieldDefaultContract
itemsrequired{ value: string; label: ReactNode; disabled?: boolean }[]; unique option identities.
mode"local""local" runs the predicate; "external" emits intent only.
onFilterrequired locally(value: string, record: T) => boolean; pure and non-throwing, forbidden externally.
multipletrueMultiple checkboxes or a single radio choice. State always uses arrays.
label"Filter {title}"Accessible trigger and popup name. Supply meaningful text for non-text titles; fallback uses the column key.

The fixed panel does not forward raw HTML, render hooks, custom descendants, or prop bags. The Popover primitive owns focus, positioning, Escape, and outside dismissal. Existing headClassName styles the header; custom filter panel structure stays outside this interface.

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, column option filtering, single-column sorting, local/external pagination, and loading / empty / caption / row className. More complex data operations remain outside this delivery:

  • Multi-column sorting, free-text filters, filter trees, and custom filter panels — derive these in your parent and feed dataSource. Use sorter: true for externally ordered data and pagination.mode: "external" for supplied pages. Combine with @tanstack/react-table for 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 loading and emptyMessage but no errorMessage. 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-wide loading is 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.

On this page