Easy Shadcn
Components

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.

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 / onValueChangeselectedRowKeys / 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 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
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 | undefinedData rows. null / undefined are treated as empty, which matches SWR / React Query pre-response states.
rowKeystring / number field of T | (record, index) => string | numberRequired. 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.
captionReactNodeRendered inside <caption>.
rowClassNameClassValue | (record, index) => ClassValuePer-row className.
onRowClick(record, index) => voidMakes 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.
defaultSelectedRowKeysstring[][]Uncontrolled initial keys.
sortTableSort | nullControlled single-column sort. null clears; undefined uses internal state.
defaultSortTableSort | nullnullInitial uncontrolled sort; ignored when sort is defined.
onSortChange(sort: TableSort | null) => voidUser-requested order. TableSort is { columnKey: string; order: "ascend" | "descend" }.
onSelectedRowKeysChange(keys, rows) => voidCalled 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.
selectionColumnClassNameClassValueclassName 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).
headerClassNameClassValueclassName on <thead>.
bodyClassNameClassValueclassName on <tbody>.
captionClassNameClassValueclassName on <caption>.
emptyClassNameClassValueclassName on the empty-state cell.
loadingClassNameClassValueclassName on the loading-state cell.
classNameClassValueclassName 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.
valueControlled 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.
totalRequired 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.
classNameClassValue 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.
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.

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