# DataTable (/docs/data-table)





`DataTable` wraps [TanStack Table](https://tanstack.com/table) (headless row/column engine) and renders through the styled [`Table`](/docs/table) primitive. Define columns once, hand it `data`, and sorting / filtering / pagination round-trip as react-table-native state — the same state a `useState` dispatch or a TanStack Query `queryKey` can bind to directly, with no kit-specific adapter in between.

```tsx
import { DataTable } from "@42-central/ui-react/data-table";

const columns = [
  { accessorKey: "name", header: "Name", enableHiding: false },
  { accessorKey: "role", header: "Role" },
  { accessorKey: "commits", header: "Commits", meta: { align: "end" } },
];

<DataTable columns={columns} data={users} />;
```

Every column is sortable by default (click the header) — no extra wiring. A power user who wants total control composes the [`Table`](/docs/table) parts with their own `useTable` instance and skips `DataTable` entirely — register `dataTableFeatures` (exported from `@42-central/ui-react/data-table`) on it so the instance satisfies the types `DataTable.Pagination` and friends expect.

## Basic [#basic]

Columns are plain [`ColumnDef`](https://tanstack.com/table/latest/docs/guide/column-defs) objects — `@42-central/ui-react/data-table` re-exports `createColumnHelper` for full type narrowing in a real app, but a plain object literal works identically at runtime (as below). `meta.align` right-aligns a numeric column.

<Preview align="block" code="&#x22;const columns = [\n  { accessorKey: 'name', header: 'Name' },\n  { accessorKey: 'role', header: 'Role' },\n  { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },\n];\n\nconst users = [\n{ id: '1', name: 'Ada Lovelace', role: 'Staff', commits: 128 },\n{ id: '2', name: 'Alan Turing', role: 'Student', commits: 57 },\n{ id: '3', name: 'Grace Hopper', role: 'Staff', commits: 203 },\n];\n\n<DataTable columns={columns} data={users} />&#x22;">
  <DataTable
    columns="[
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'role', header: 'Role' },
    { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },
  ]"
    data="[
    { id: '1', name: 'Ada Lovelace', role: 'Staff', commits: 128 },
    { id: '2', name: 'Alan Turing', role: 'Student', commits: 57 },
    { id: '3', name: 'Grace Hopper', role: 'Staff', commits: 203 },
  ]"
  />
</Preview>

## Resizable columns [#resizable-columns]

By default (`sizing="fixed"`) every column renders at a real, stable width — no more reflow when a filter or sort changes the visible rows. The underlying `<table>` is `table-layout: fixed`, sized off react-table's own [`size` / `minSize`](https://tanstack.com/table/latest/docs/guide/column-sizing) (plain `ColumnDef` fields — a column with neither renders at react-table's 150px default, a real behavior change from `DataTable`'s initial release, where columns sized themselves from whatever row content happened to be visible). Set `enableColumnResizing` to let the end user drag a column's trailing edge to their own preference — the handle wires react-table's built-in mouse *and* touch handler, and supports `ArrowLeft` / `ArrowRight` when focused. To persist resized widths across reloads, own `columnSizing` yourself — the kit's `useLocalStorage` hook (also exported from `@42-central/ui-react`) returns a `[value, setValue]` pair that plugs straight into `columnSizing` / `onColumnSizingChange`, keeping every open tab on the same value in sync too. Set `sizing="auto"` to opt back into content-based sizing instead — see below.

`fillLastColumn` (on by default) stretches the last visible column to absorb any width left over once every other column has its own size, so the table fills its container instead of leaving a gap when the column sizes add up to less than the available space — the two previews above already have it on (widen your browser to see the `Commits` column stretch). Set it to `false` to give the last column its own `size`/`minSize` back, gap and all — its resize handle also returns, since there's a real width for the drag to adjust again.

<Preview align="block" code="&#x22;const columns = [\n  { accessorKey: 'name', header: 'Name', size: 200, minSize: 120 },\n  { accessorKey: 'role', header: 'Role', size: 120 },\n  { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },\n];\n\nconst [columnSizing, setColumnSizing] = useLocalStorage({\nkey: 'users-table',\ndefaultValue: {},\n});\n\n<DataTable\n  columns={columns}\n  data={users}\n  enableColumnResizing\n  columnSizing={columnSizing}\n  onColumnSizingChange={setColumnSizing}\n/>&#x22;">
  <ResizableColumnsDemo />
</Preview>

### Content-based sizing [#content-based-sizing]

`sizing="auto"` opts back into the browser's own `table-layout: auto` — columns size themselves from whatever rows are currently visible, `size` / `minSize` are ignored, and `enableColumnResizing` has no effect (there's no fixed width for a drag to adjust). This is `DataTable`'s original pre-resize behavior: reach for it only when a table's content is short and uniform enough that reflow-on-filter isn't a real concern, since that's the exact tradeoff `sizing="fixed"` (the default) exists to avoid.

<Preview align="block" code="`<DataTable columns={columns} data={users} sizing=&#x22;auto&#x22; />`">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    { accessorKey: &#x22;commits&#x22;, header: &#x22;Commits&#x22;, meta: { align: &#x22;end&#x22; } },
  ]"
    data="[
    { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22;, commits: 128 },
    { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22;, commits: 57 },
    { id: &#x22;3&#x22;, name: &#x22;Grace Hopper&#x22;, role: &#x22;Staff&#x22;, commits: 203 },
  ]"
    sizing="auto"
  />
</Preview>

## Column filters [#column-filters]

Set `meta.filter` on a column to add a header filter affordance — an icon button opening a popover whose body matches the discriminant: `text` (debounced), `select` / multi-select, `number`, `date`, or `date-range`. `number` is a plain min/max (from/to) range: fill either or both, and the filter derives whether that's "at least," "at most," or "between" — no operator to pick. `date` / `date-range` open the same calendar + presets(+ time) popover [`DatePicker`](/docs/date-picker) itself uses — see [Date and date-range](#date-and-date-range) below. `DataTable` also wires the matching client-side `filterFn` automatically from `meta.filter.type`, so `getFilteredRowModel` filters correctly without `manualFiltering`. An active filter shows a dot indicator, not just a color change.

<Preview align="block" code="&#x22;const columns = [\n  { accessorKey: 'name', header: 'Name', meta: { filter: { type: 'text', placeholder: 'Search name…' } } },\n  {\n    accessorKey: 'role',\n    header: 'Role',\n    meta: { filter: { type: 'select', multiple: true, options: ['Staff', 'Student'] } },\n  },\n  {\n    accessorKey: 'commits',\n    header: 'Commits',\n    meta: { align: 'end', filter: { type: 'number', min: 0, max: 2000 } },\n  },\n];\n\n<DataTable columns={columns} data={users} />&#x22;">
  <DataTable
    columns="[
    { accessorKey: 'name', header: 'Name', meta: { filter: { type: 'text', placeholder: 'Search name…' } } },
    {
      accessorKey: 'role',
      header: 'Role',
      meta: { filter: { type: 'select', multiple: true, options: ['Staff', 'Student'] } },
    },
    {
      accessorKey: 'commits',
      header: 'Commits',
      meta: { align: 'end', filter: { type: 'number', min: 0, max: 2000 } },
    },
  ]"
    data="[
    { id: '1', name: 'Ada Lovelace', role: 'Staff', commits: 128 },
    { id: '2', name: 'Alan Turing', role: 'Student', commits: 57 },
    { id: '3', name: 'Grace Hopper', role: 'Staff', commits: 203 },
    { id: '4', name: 'Katherine Johnson', role: 'Staff', commits: 84 },
  ]"
  />
</Preview>

<Alert
  type="info"
  withCloseButton="false"
  className="w-full max-w-none my-4"
  description="
  <>
    Filtering (and sorting) resets pagination back to page 0 automatically —
    but only when it's this header/filter/search UI doing the changing. See{&#x22; &#x22;}
    <a href=&#x22;#controlled-state&#x22;>Controlled state</a> for the one case where
    you need to reset it yourself.
  </>
"
/>

### Single select [#single-select]

Omit `multiple` for a single-value picker — the popover closes itself the instant a value is
picked, matching `Select`'s own close-on-pick habit (multi-select stays open so more values can
be toggled).

<Preview align="block" code="&#x22;const columns = [\n  { accessorKey: 'name', header: 'Name' },\n  {\n    accessorKey: 'role',\n    header: 'Role',\n    meta: { filter: { type: 'select', options: ['Staff', 'Student'] } },\n  },\n];\n\n<DataTable columns={columns} data={users} />&#x22;">
  <DataTable
    columns="[
    { accessorKey: 'name', header: 'Name' },
    {
      accessorKey: 'role',
      header: 'Role',
      meta: { filter: { type: 'select', options: ['Staff', 'Student'] } },
    },
  ]"
    data="[
    { id: '1', name: 'Ada Lovelace', role: 'Staff' },
    { id: '2', name: 'Alan Turing', role: 'Student' },
    { id: '3', name: 'Grace Hopper', role: 'Staff' },
  ]"
  />
</Preview>

### Date and date-range [#date-and-date-range]

`date` / `date-range` open a popover with the same calendar grid + presets rail (+ time row under
`withTime`) [`DatePicker`](/docs/date-picker) itself uses — composed directly from its exported
`Root`/`Content`/`Presets`/`Calendar`/`TimePanelRow` parts, not a separate implementation. A click
picks the range's start, a second click its end. `date`'s stored value still derives an operator
from that pair — a lone click is "on or after," two clicks is "between" — while `date-range`
always treats the pair as a plain inclusive range, each bound independently optional. Because the
calendar's clicks are *ordered* (start, then end), there's no interaction that produces an
upper-bound-only "on or before" value the way the old two-independent-inputs UI could — that
operator is still fully supported, just settable only programmatically via controlled
`columnFilters`, not through this popover.

`presets` (mirroring `DatePicker`'s own prop, on "Hired" below) adds a shortcuts sidebar — `true`
for a mode-appropriate default set, or a custom `DatePreset[]`. `withTime` (on "Last review")
adds an embedded time row and compares exact instants rather than whole calendar days.

<Preview align="block" code="&#x22;const columns = [\n  { accessorKey: 'name', header: 'Name' },\n  { accessorKey: 'hired', header: 'Hired', meta: { filter: { type: 'date', presets: true } } },\n  {\n    accessorKey: 'lastReview',\n    header: 'Last review',\n    meta: { filter: { type: 'date-range', withTime: true } },\n  },\n];\n\n<DataTable columns={columns} data={users} />&#x22;">
  <DataTable
    columns="[
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'hired', header: 'Hired', meta: { filter: { type: 'date', presets: true } } },
    {
      accessorKey: 'lastReview',
      header: 'Last review',
      meta: { filter: { type: 'date-range', withTime: true } },
    },
  ]"
    data="[
    { id: '1', name: 'Ada Lovelace', hired: '2021-03-15', lastReview: '2024-01-10T09:15:00' },
    { id: '2', name: 'Alan Turing', hired: '2022-07-01', lastReview: '2024-06-22T16:45:00' },
    { id: '3', name: 'Grace Hopper', hired: '2019-11-20', lastReview: '2023-09-05T11:00:00' },
  ]"
  />
</Preview>

## Global search, column visibility & a title bar [#global-search-column-visibility--a-title-bar]

`title` and `toolbarActions` render a `Table.Toolbar` above the table; `enableGlobalFilter` adds a debounced search box bound to `globalFilter`, and `enableColumnVisibility` adds a checkbox menu of the table's columns. The bar only renders when at least one of these is set.

A column with `enableHiding: false` still appears in that menu (and in the stacked layout's own "Columns" section), checked and disabled — it's listed so the end user can see it exists, just can't be turned off. For a utility/action column that was never a real visibility choice in the first place (a trailing "view" button, say), set `meta.hideFromMenu` instead — it's excluded from both lists entirely, independent of `enableHiding`.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={users}\n  title=\&#x22;users\&#x22;\n  toolbarActions={<Button size=\&#x22;sm\&#x22;>Create user</Button>}\n  enableGlobalFilter\n  enableColumnResizing\n  enableColumnVisibility\n/>&#x22;">
  <DataTable
    columns="[
    {
      accessorKey: &#x22;name&#x22;,
      header: &#x22;Name&#x22;,
      enableHiding: false,
      meta: {
        filter: {
          type: &#x22;text&#x22;,
          placeholder: &#x22;Search name…&#x22;,
        },
      },
    },
    {
      accessorKey: &#x22;role&#x22;,
      header: &#x22;Role&#x22;,
      meta: {
        filter: {
          type: &#x22;select&#x22;,
          multiple: true,
          options: [&#x22;Staff&#x22;, &#x22;Student&#x22;],
        },
      },
    },
    {
      accessorKey: &#x22;commits&#x22;,
      header: &#x22;Commits&#x22;,
      meta: { align: &#x22;end&#x22;, filter: { type: &#x22;number&#x22;, min: 0, max: 2000 } },
    },
  ]"
    data="[
    { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22;, commits: 128 },
    { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22;, commits: 57 },
    { id: &#x22;3&#x22;, name: &#x22;Grace Hopper&#x22;, role: &#x22;Staff&#x22;, commits: 203 },
  ]"
    title="Users"
    toolbarActions="
    <Button size=&#x22;sm&#x22; key={0} startSlot={<UserPlusIcon />}>
      Create user
    </Button>
  "
  />
</Preview>

### Non-text headers (`meta.label`) [#non-text-headers-metalabel]

When a column's `header` isn't plain text — an icon, say — `meta.label` supplies the text used
everywhere a label would otherwise fall back to a string `header`: the column-visibility menu
above, and a stacked card's label in `responsive="stack"` below.

<Preview align="block" code="&#x22;<DataTable\n  columns={[\n    { accessorKey: 'name', header: 'Name' },\n    {\n      accessorKey: 'verified',\n      header: () => <ShieldCheckIcon className=\&#x22;size-4\&#x22; />,\n      meta: { label: 'Verified' },\n      cell: ({ getValue }) => (\n        <Badge color={getValue() ? 'green' : 'neutral'} size=\&#x22;sm\&#x22;>\n          {getValue() ? 'Verified' : 'Pending'}\n        </Badge>\n      ),\n    },\n  ]}\n  data={users}\n  enableColumnVisibility\n/>&#x22;">
  <NonTextHeaderDemo />
</Preview>

## Pagination [#pagination]

`DataTable` renders a pagination footer automatically — a page-size `Select`, prev/next, and a current-page indicator, all driven off `getPaginationRowModel()` for client-side data (the default). Flip `manualPagination` for server-owned pages (see [Query integration](#query-integration) below). Set `enablePagination={false}` to hide the footer entirely; override its wording with `translations` (see [API](#api) below).

By default (`paginationMode="page"`) the indicator shows the page number, with the row range and total revealed in a tooltip on hover or focus. `paginationMode="rows"` mirrors that — the row range and total are always visible, with the page number moved into the tooltip instead — see [Always-visible row range](#always-visible-row-range) below.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={/* 24 users */}\n  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    { accessorKey: &#x22;commits&#x22;, header: &#x22;Commits&#x22;, meta: { align: &#x22;end&#x22; } },
  ]"
    data="Array.from({ length: 24 }, (_, i) => ({
    id: String(i + 1),
    name: `Contributor ${i + 1}`,
    role: i % 3 === 0 ? &#x22;Staff&#x22; : &#x22;Student&#x22;,
    commits: (i + 1) * 7,
  }))"
    initialState="{ pagination: { pageIndex: 0, pageSize: 5 } }"
  />
</Preview>

### Custom page sizes [#custom-page-sizes]

`pageSizeOptions` replaces the default `[10, 20, 50]` choices in the page-size `Select`.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={/* 24 users */}\n  pageSizeOptions={[5, 15, 30]}\n  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    { accessorKey: &#x22;commits&#x22;, header: &#x22;Commits&#x22;, meta: { align: &#x22;end&#x22; } },
  ]"
    data="Array.from({ length: 24 }, (_, i) => ({
    id: String(i + 1),
    name: `Contributor ${i + 1}`,
    role: i % 3 === 0 ? &#x22;Staff&#x22; : &#x22;Student&#x22;,
    commits: (i + 1) * 7,
  }))"
    pageSizeOptions="[5, 15, 30]"
    initialState="{ pagination: { pageIndex: 0, pageSize: 5 } }"
  />
</Preview>

### Always-visible row range [#always-visible-row-range]

`paginationMode="rows"` renders the row range and total inline instead of the page number, with no
hover or focus needed — and announces it via `role="status"` as the page changes. The page number
isn't lost: it moves into the tooltip, the mirror image of the default. Useful when that count is
information the table exists to answer, not a detail to reveal on request.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={/* 24 users */}\n  paginationMode=\&#x22;rows\&#x22;\n  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    { accessorKey: &#x22;commits&#x22;, header: &#x22;Commits&#x22;, meta: { align: &#x22;end&#x22; } },
  ]"
    data="Array.from({ length: 24 }, (_, i) => ({
    id: String(i + 1),
    name: `Contributor ${i + 1}`,
    role: i % 3 === 0 ? &#x22;Staff&#x22; : &#x22;Student&#x22;,
    commits: (i + 1) * 7,
  }))"
    paginationMode="rows"
    initialState="{ pagination: { pageIndex: 0, pageSize: 5 } }"
  />
</Preview>

### Unknown total (cursor-style) [#unknown-total-cursor-style]

Omit `rowCount` (and set `pageCount={-1}`, react-table's own "unknown" sentinel) for a
cursor-backed source that can't say how many pages there are — the footer degrades to "Page N"
with prev/next-only, gated by `hasNextPage` instead of a last-page jump.

<Preview align="block" code="&#x22;function CursorTable() {\n  const [pageIndex, setPageIndex] = useState(0);\n  const pageSize = 5;\n  const page = fetchCursorPage(pageIndex, pageSize); // your own cursor source\n  return (\n    <DataTable\n      columns={columns}\n      data={page.items}\n      manualPagination\n      pageCount={-1}\n      hasNextPage={page.hasMore}\n      pagination={{ pageIndex, pageSize }}\n      onPaginationChange={(updater) => {\n        const next = typeof updater === 'function' ? updater({ pageIndex, pageSize }) : updater;\n        setPageIndex(next.pageIndex);\n      }}\n    />\n  );\n}&#x22;">
  <HasNextPageDemo />
</Preview>

## Row selection [#row-selection]

`enableRowSelection` auto-injects a leading checkbox column — an indeterminate select-all in the header, a per-row checkbox with a visually-hidden accessible name. Selected rows tint from the `--c-soft` slot var.

<Preview align="block" code="`<DataTable columns={columns} data={users} enableRowSelection />`">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    { accessorKey: &#x22;commits&#x22;, header: &#x22;Commits&#x22;, meta: { align: &#x22;end&#x22; } },
  ]"
    data="[
    { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22;, commits: 128 },
    { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22;, commits: 57 },
    { id: &#x22;3&#x22;, name: &#x22;Grace Hopper&#x22;, role: &#x22;Staff&#x22;, commits: 203 },
  ]"
  />
</Preview>

### Conditional selection [#conditional-selection]

`enableRowSelection` also takes a predicate — `(row) => boolean` — to disable selecting some rows
instead of all-or-nothing. The header checkbox's indeterminate/select-all state only ever
accounts for the currently selectable rows.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={users}\n  enableRowSelection={(row) => row.original.role === 'Staff'}\n/>&#x22;">
  <ConditionalSelectionDemo />
</Preview>

## Row click [#row-click]

`onRowClick` fires unless the click landed on a nested interactive element (a link, button,
checkbox, menu, …), on content a cell portals out of the row (its own Menu or Dialog), or ended a
text selection over the row — so it composes safely with row selection, a trailing action column
and copying a cell's value instead of stealing their clicks. Only the row's own content is checked,
so the table can sit inside a `Tabs` panel or a dialog. It's a mouse-only convenience, though: rows aren't themselves focusable,
so `onRowClick` should **never be a row's only affordance** — pair it with a real link or button,
as the "View" column below does.

<Preview align="start" code="&#x22;function UsersTable() {\n  const [selected, setSelected] = useState(null);\n  return (\n    <>\n      <DataTable\n        columns={[\n          { accessorKey: 'name', header: 'Name' },\n          { accessorKey: 'role', header: 'Role' },\n          {\n            id: 'view',\n            header: '',\n            cell: ({ row }) => (\n              <Button size=\&#x22;xs\&#x22; variant=\&#x22;subtle\&#x22; onClick={() => setSelected(row.original)}>\n                View\n              </Button>\n            ),\n          },\n        ]}\n        data={users}\n        onRowClick={setSelected}\n      />\n      <Text size=\&#x22;sm\&#x22; c=\&#x22;muted\&#x22;>\n        {selected ? `Selected: ${selected.name}` : 'Click a row (or its View button).'}\n      </Text>\n    </>\n  );\n}&#x22;">
  <RowClickDemo />
</Preview>

## Loading, empty & error states [#loading-empty--error-states]

`loading` shows skeleton rows on first load, then dims existing rows on a refetch (pairs with TanStack Query's `keepPreviousData`). `empty` and `error` replace the body — `error` is a `role="alert"` region, and `onRetry` is a real callback for its "Retry" button, not decoration.

<Preview align="start" code="&#x22;<div className=\&#x22;flex w-full flex-col gap-6\&#x22;>\n  <DataTable\n    columns={[\n      { accessorKey: \&#x22;name\&#x22;, header: \&#x22;Name\&#x22; },\n      { accessorKey: \&#x22;role\&#x22;, header: \&#x22;Role\&#x22; },\n    ]}\n    data={[]}\n    loading\n  />\n  <DataTable\n    columns={[\n      { accessorKey: \&#x22;name\&#x22;, header: \&#x22;Name\&#x22; },\n      { accessorKey: \&#x22;role\&#x22;, header: \&#x22;Role\&#x22; },\n    ]}\n    data={[]}\n    empty={{\n      title: \&#x22;No users match your filters.\&#x22;,\n      description: \&#x22;Some description\&#x22;,\n    }}\n  />\n  <ErrorRetryDemo />\n</div>&#x22;">
  <div className="flex w-full flex-col gap-6">
    <DataTable
      columns="[
      { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
      { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    ]"
      data="[]"
    />

    <DataTable
      columns="[
      { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
      { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    ]"
      data="[]"
      empty="{
      title: &#x22;No users match your filters.&#x22;,
      description: &#x22;Some description&#x22;,
    }"
    />

    <ErrorRetryDemo />
  </div>
</Preview>

### Refetching [#refetching]

Setting `loading` while `data` already has rows dims the existing rows instead of swapping in
skeletons — the treatment a `keepPreviousData`-style refetch gets, versus the from-empty first
load above.

<Preview align="start" code="&#x22;function UsersTable() {\n  const [loading, setLoading] = useState(false);\n  const refetch = () => {\n    setLoading(true);\n    fetchUsers().then(() => setLoading(false));\n  };\n  return (\n    <>\n      <Button size=\&#x22;sm\&#x22; variant=\&#x22;light\&#x22; color=\&#x22;neutral\&#x22; onClick={refetch} loading={loading}>\n        Refetch\n      </Button>\n      <DataTable columns={columns} data={users} loading={loading} />\n    </>\n  );\n}&#x22;">
  <RefetchingDemo />
</Preview>

### Column-specific skeleton content (`meta.skeleton`) [#column-specific-skeleton-content-metaskeleton]

The default skeleton bar is sized to match a plain text cell at the table's current density automatically, with no per-column setup — both the scroll-table and stacked-card views render it through the shared [`Skeleton`](/docs/skeleton) component. A column whose real cell isn't text — a thumbnail, an icon button — sets `meta.skeleton` to override it with a same-shaped placeholder instead, so the row doesn't visibly resize once real data replaces the skeleton. Build it from `DATA_TABLE_SKELETON_CLASS` (also exported from `@42-central/ui-react/data-table`) to match the default bar's shimmer/fill/radius.

<Preview align="start" code="&#x22;import { DATA_TABLE_SKELETON_CLASS } from '@42-central/ui-react/data-table';\n\n<DataTable\n  columns={[\n    { accessorKey: 'name', header: 'Name' },\n    {\n      accessorKey: 'avatar',\n      header: '',\n      meta: {\n        align: 'center',\n        skeleton: <div className={cn(DATA_TABLE_SKELETON_CLASS, 'size-8 rounded-full')} />,\n      },\n    },\n  ]}\n  data={[]}\n  loading\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    {
      accessorKey: &#x22;avatar&#x22;,
      header: &#x22;&#x22;,
      meta: {
        align: &#x22;center&#x22;,
        skeleton: <div className={cn(DATA_TABLE_SKELETON_CLASS, &#x22;size-8 rounded-full&#x22;)} />,
      },
    },
  ]"
    data="[]"
  />
</Preview>

## Presentation [#presentation]

`size`, `striped`, and `stickyHeader` forward straight to the underlying [`Table`](/docs/table)
primitive.

### Density [#density]

<Preview align="start" code="&#x22;<DataTable columns={columns} data={users} size=\&#x22;xs\&#x22; />\n<DataTable columns={columns} data={users} size=\&#x22;md\&#x22; />\n<DataTable columns={columns} data={users} size=\&#x22;xl\&#x22; />&#x22;">
  <div className="flex w-full flex-col gap-4">
    <DataTable
      columns="[
      { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
      { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    ]"
      data="[
      { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22; },
      { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22; },
    ]"
      size="xs"
    />

    <DataTable
      columns="[
      { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
      { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    ]"
      data="[
      { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22; },
      { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22; },
    ]"
      size="md"
    />

    <DataTable
      columns="[
      { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
      { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
    ]"
      data="[
      { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22; },
      { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22; },
    ]"
      size="xl"
    />
  </div>
</Preview>

### Striped rows + sticky header [#striped-rows--sticky-header]

`stickyHeader` pins the header row while the body scrolls — it (and `stickyFooter`) need a
bounded height on the table (or `Table.Content`) to have anything to stick within.
`stickyFooter` is the same idea for a `Table.Foot` totals row, but `DataTable` doesn't render a
footer row itself (no column `footer` def, no foot slot yet) — reach for
[`Table`](/docs/table#sticky-header-footer)'s own composition if you need one.

<Preview align="block" code="`<DataTable columns={columns} data={users} striped stickyHeader className=&#x22;max-h-64&#x22; />`">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
  ]"
    data="Array.from({ length: 10 }, (_, i) => ({
    id: String(i + 1),
    name: `Contributor ${i + 1}`,
    role: i % 3 === 0 ? &#x22;Staff&#x22; : &#x22;Student&#x22;,
  }))"
    className="max-h-64"
  />
</Preview>

### `fillLastColumn={false}` [#filllastcolumnfalse]

Off, the last column keeps its own `size`/`minSize` — and its resize handle — instead of
stretching to absorb the container's leftover width (contrast with the resizing previews above,
which all leave this on).

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={users}\n  enableColumnResizing\n  fillLastColumn={false}\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22;, size: 200, minSize: 120 },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22;, size: 120 },
    {
      accessorKey: &#x22;commits&#x22;,
      header: &#x22;Commits&#x22;,
      meta: { align: &#x22;end&#x22; },
      size: 120,
    },
  ]"
    data="[
    { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22;, commits: 128 },
    { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22;, commits: 57 },
  ]"
    fillLastColumn="false"
  />
</Preview>

### Slot overrides (`classNames`) [#slot-overrides-classnames]

Every part of the render tree — `root`, `table`, `head`, `headerRow`, `headerCell`, `body`, `row`,
`cell`, `empty`, `error`, `toolbar`, `pagination` — takes its own class through `classNames`,
merged with (not replacing) the part's own styling.

<Preview align="block" code="&#x22;<DataTable\n  columns={columns}\n  data={users}\n  classNames={{\n    headerRow: 'bg-brand-50 dark:bg-brand-950',\n    row: 'hover:bg-brand-50/50 dark:hover:bg-brand-950/50',\n  }}\n/>&#x22;">
  <DataTable
    columns="[
    { accessorKey: &#x22;name&#x22;, header: &#x22;Name&#x22; },
    { accessorKey: &#x22;role&#x22;, header: &#x22;Role&#x22; },
  ]"
    data="[
    { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22; },
    { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22; },
  ]"
    classNames="{
    headerRow: &#x22;bg-brand-50 dark:bg-brand-950&#x22;,
    row: &#x22;hover:bg-brand-50/50 dark:hover:bg-brand-950/50&#x22;,
  }"
  />
</Preview>

## Responsive: stacked cards [#responsive-stacked-cards]

`responsive="stack"` renders each row as a `Card` of `label: value` pairs below a container-query breakpoint (`@lg`), instead of the `<table>` row — useful when columns can't reasonably fit a narrow viewport. Both renderings exist in the DOM; only one is visible at a given container width. This is an explicit accessibility tradeoff: stacked cards drop the row/column semantics screen-reader users rely on, so `"scroll"` (the default) stays the SR-faithful path. The example below is wrapped in a narrow container to force the stacked view regardless of your screen width.

A stacked table has no header row, so a **"Table options" button** appears above the cards whenever there's a sortable column, a filterable column, or (with `enableColumnVisibility`) a hideable column — it opens a bottom `Drawer` with the same sort toggles, filter controls, and column-visibility checkboxes the desktop header/toolbar expose, consolidated into one mobile-friendly editor.

<Preview align="block" code="`<DataTable columns={columns} data={users} responsive=&#x22;stack&#x22; enableColumnVisibility />`">
  <div className="max-w-xs">
    <DataTable
      columns="[
      {
        accessorKey: &#x22;name&#x22;,
        header: &#x22;Name&#x22;,
        meta: { filter: { type: &#x22;text&#x22;, placeholder: &#x22;Search name…&#x22; } },
      },
      {
        accessorKey: &#x22;role&#x22;,
        header: &#x22;Role&#x22;,
        meta: {
          filter: {
            type: &#x22;select&#x22;,
            multiple: true,
            options: [&#x22;Staff&#x22;, &#x22;Student&#x22;],
          },
        },
      },
      {
        accessorKey: &#x22;commits&#x22;,
        header: &#x22;Commits&#x22;,
        meta: { align: &#x22;end&#x22;, filter: { type: &#x22;number&#x22;, min: 0, max: 2000 } },
      },
    ]"
      data="[
      { id: &#x22;1&#x22;, name: &#x22;Ada Lovelace&#x22;, role: &#x22;Staff&#x22;, commits: 128 },
      { id: &#x22;2&#x22;, name: &#x22;Alan Turing&#x22;, role: &#x22;Student&#x22;, commits: 57 },
    ]"
      responsive="stack"
      title="Users"
    />
  </div>
</Preview>

## Schema-derived columns [#schema-derived-columns]

Rather than re-declaring an entity's shape by hand, derive it from a schema you already validate against. `columnHelperFor` targets the vendor-neutral [Standard Schema](https://standardschema.dev) spec — implemented by Zod v4, Valibot, and ArkType — so the kit depends on the *spec*, never on any one validator. **Type inference only**: v1 ships no runtime row validation.

```tsx
import { z } from "zod";
import { columnHelperFor, DataTable } from "@42-central/ui-react/data-table";

const UserSchema = z.object({
  id: z.string(),
  email: z.email(),
  age: z.number(),
  role: z.enum(["student", "staff"]),
});

const col = columnHelperFor(UserSchema);
//    ^ ColumnHelper<{ id: string; email: string; age: number; role: 'student' | 'staff' }>

const columns = [
  col.accessor("email", { header: "Email" }),
  col.accessor("role", {
    header: "Role",
    meta: { filter: { type: "select", options: ["student", "staff"] } },
  }),
];

// Passing `schema` on DataTable constrains `data` / `columns` to the same inferred type end-to-end.
<DataTable schema={UserSchema} columns={columns} data={rows} />;
```

## Controlled state [#controlled-state]

Every state slice — `sorting`, `filters`, `globalFilter`, `pagination`, `rowSelection`,
`columnVisibility`, `columnSizing` — takes the same controlled `value` / `onChange` shape, so a
plain `useState` round-trips exactly like the uncontrolled default above. One live proof (sorting

* pagination together, with the raw state printed below) stands in for all seven — the UI is
  identical either way, only the wiring changes. See [Query integration](#query-integration) right
  below for the full real-world recipe (server-owned state via `manualSorting` /
  `manualFiltering` / `manualPagination`, plus mapping the state into an API's own query params).

<Preview align="start" code="&#x22;function ContributorsTable() {\n  const [sorting, setSorting] = useState([]);\n  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });\n  return (\n    <DataTable\n      columns={columns}\n      data={contributors}\n      sorting={sorting}\n      onSortingChange={setSorting}\n      pagination={pagination}\n      onPaginationChange={setPagination}\n    />\n  );\n}&#x22;">
  <ControlledStateDemo />
</Preview>

<Alert
  type="warning"
  withCloseButton="false"
  className="w-full max-w-none my-4"
  title="Pagination reset only follows DataTable's own controls"
  description="
  <>
    Changing <code>sorting</code>, <code>filters</code>, or{&#x22; &#x22;}
    <code>globalFilter</code> through DataTable's own UI — a header click, a
    filter popover, the search box — always resets <code>pagination</code>'s{&#x22; &#x22;}
    <code>pageIndex</code> back to <code>0</code>, so a new sort/filter/search
    never leaves you stranded on a page that no longer has any rows. If one of
    those three changes from outside DataTable instead — bypassing{&#x22; &#x22;}
    <code>onSortingChange</code> / <code>onFiltersChange</code> /{&#x22; &#x22;}
    <code>onGlobalFilterChange</code> entirely — DataTable has no way to
    notice, and won't reset <code>pagination</code> for you. Reset{&#x22; &#x22;}
    <code>pageIndex</code> back to <code>0</code> yourself, in that same state
    update.
  </>
"
/>

## Query integration [#query-integration]

The kit ships no query-library adapter — `DataTable`'s controlled state (`sorting`, `filters`, `globalFilter`, `pagination`, …) is react-table-native on both sides, so a plain `useState` dispatch **is** a valid `onSortingChange` etc. Wiring to TanStack Query is a two-line `queryKey` + `queryFn`, no kit-specific glue:

```tsx
"use client";
import { useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { DataTable } from "@42-central/ui-react/data-table";
import { columns } from "./columns";

export function UsersTable() {
  const [sorting, setSorting] = useState([]);
  const [filters, setFilters] = useState([]);
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 });

  const q = useQuery({
    queryKey: ["users", { sorting, filters, pagination }], // state drops straight in
    queryFn: () => fetchUsers({ sorting, filters, pagination }),
    placeholderData: keepPreviousData, // no flash between pages
  });

  return (
    <DataTable
      columns={columns}
      data={q.data?.rows ?? []}
      rowCount={q.data?.total}
      loading={q.isLoading}
      error={q.isError ? "Failed to load users" : undefined}
      manualSorting
      manualFiltering
      manualPagination
      sorting={sorting}
      onSortingChange={setSorting}
      filters={filters}
      onFiltersChange={setFilters}
      pagination={pagination}
      onPaginationChange={setPagination}
    />
  );
}
```

For 42's own `SortQuery` / `FilterQuery` / paginated-response contract, map the state into query params once per app:

```ts
import { match, P } from "ts-pattern";

type ApiFilter = Record<string, unknown>;
type FilterType = "number" | "date" | "date-range" | "text" | string;
type TableFilter = {
  id: string;
  value: unknown;
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null;

type NumberFilterValue =
  | { op: "eq" | "gt" | "gte" | "lt" | "lte"; value?: number }
  | { op: "between"; min?: number; max?: number };
type DateFilterValue =
  | { op: "eq" | "gt" | "gte" | "lt" | "lte"; date?: string }
  | { op: "between"; from?: string; to?: string };

const hasNumberOp = (value: unknown): value is NumberFilterValue =>
  isRecord(value) && typeof value.op === "string";
const hasDateOp = (value: unknown): value is DateFilterValue =>
  isRecord(value) && typeof value.op === "string";

const toApiFilter = ({ id, value }: TableFilter, type: FilterType): ApiFilter =>
  match({ type, value })
    .with({ type: "number", value: P.when(hasNumberOp) }, ({ value: v }) =>
      v.op === "between"
        ? {
            ...(v.min != null && { [`${id}[gte]`]: v.min }),
            ...(v.max != null && { [`${id}[lte]`]: v.max }),
          }
        : v.value != null
          ? { [`${id}[${v.op}]`]: v.value }
          : {},
    )
    .with({ type: "date", value: P.when(hasDateOp) }, ({ value: v }) =>
      v.op === "between"
        ? {
            ...(v.from && { [`${id}[gte]`]: v.from }),
            ...(v.to && { [`${id}[lte]`]: v.to }),
          }
        : v.date
          ? { [`${id}[${v.op}]`]: v.date }
          : {},
    )
    .with(
      { type: "date-range", value: P.when(isRecord) },
      ({ value: { from, to } }) => ({
        ...(from && { [`${id}[gte]`]: from }),
        ...(to && { [`${id}[lte]`]: to }),
      }),
    )
    .with({ value: P.array(P.any) }, ({ value }) => ({
      [`${id}[in]`]: value.join(","),
    }))
    .with({ type: "text" }, () => ({
      [`${id}[like]`]: value,
    }))
    .with({ type: P.union("number", "date", "date-range") }, () => ({}))
    .otherwise(() => ({
      [id]: value,
    }));

function toApiQuery(
  {
    sorting,
    filters,
    pagination,
  }: {
    sorting: { id: string; desc: boolean }[];
    filters: TableFilter[];
    pagination: { pageIndex: number; pageSize: number };
  },
  filterType: (id: string) => FilterType,
) {
  const sort = sorting.map(({ id, desc }) => (desc ? `-${id}` : id)).join(",");

  const filter = filters.reduce<ApiFilter>(
    (query, filter) =>
      Object.assign(query, toApiFilter(filter, filterType(filter.id))),
    {},
  );

  return {
    sort,
    filter,
    page: pagination.pageIndex + 1,
    hitsPerPage: pagination.pageSize,
  };
}

// queryFn — the response maps straight back onto DataTable props:
const res = await fetchUsers(toApiQuery(state, typeOf));
return { rows: res.hits, total: res.pagination.nbHits };
```

`page` is 1-based (react-table's `pageIndex` is 0-based); half-open ranges emit only the defined bound. See the [RFC](https://github.com/42staff/kit/blob/main/docs/rfcs/data-table.md) for the full contract, including the `in`-range-only caveat on `date-range` filters.

Every example above runs against a small in-memory array. For the same contract wired to a real
third-party API — server-driven sorting, filtering, and search (synced to the URL) all
round-tripping as genuine network requests, real loading/error/retry, and a row-detail Drawer — see
the [Magic: The Gathering example](/docs/data-table/magic).

## Translations [#translations]

`translations` is the one prop for every piece of wording `DataTable` renders: the pagination footer, the accessible names of the header controls (sort, filter, resize, select-all, column visibility), the empty and error actions, the `number` filter's placeholders and the stacked layout's options drawer. English defaults ship in the kit, and a partial override only replaces the keys you set. Keys that name a column take a function, so word order stays the translator's call.

<Preview align="block" code="&#x22;const SORT_MODE_FR = { asc: \&#x22;croissant\&#x22;, desc: \&#x22;décroissant\&#x22;, none: \&#x22;aucun\&#x22; };\n\n<DataTable\n  columns={columns}\n  data={users}\n  enableRowSelection\n  enableColumnVisibility\n  title=\&#x22;Contributeurs\&#x22;\n  translations={{\n    rows: \&#x22;Lignes par page\&#x22;,\n    previousPage: \&#x22;Page précédente\&#x22;,\n    nextPage: \&#x22;Page suivante\&#x22;,\n    items: ({ from, to, hits }) => `${from}–${to} sur ${hits}`,\n    selectAllRows: \&#x22;Tout sélectionner\&#x22;,\n    selectRow: (id) => `Sélectionner la ligne ${id}`,\n    columnVisibility: \&#x22;Colonnes affichées\&#x22;,\n    triggerLabel: \&#x22;Afficher les options\&#x22;,\n    sortColumn: (label, mode) => `Trier ${label} (${SORT_MODE_FR[mode]})`,\n  }}\n/>&#x22;">
  <TranslationsDemo />
</Preview>

## Row, cell & header test ids [#row-cell--header-test-ids]

Every row, cell, and header gets a `data-testid` automatically — from the row's resolved id
(`getRowId`, or react-table's own index-based fallback) and each column's id — with no
configuration required:

```tsx
<DataTable columns={columns} data={users} />
// → data-testid="row-0", "row-0-cell-email", "header-email", …
```

Override the row scheme with `getRowTestId`; every cell still derives from whatever it returns.
See the [Testing](/docs/testing) guide for the full convention and a Playwright example against
a `DataTable` row.

## Subcomponents [#subcomponents]

<TypeTable
  type="{
  DataTable: {
    type: &#x22;component&#x22;,
    description: &#x22;Root — see the props reference below.&#x22;,
  },
  &#x22;DataTable.Pagination&#x22;: {
    type: &#x22;component&#x22;,
    description:
      &#x22;The pagination footer DataTable renders automatically. Exported for a power user composing the Table parts with their own useTable instance (registering the exported dataTableFeatures) — takes { table, pageSizeOptions?, hasNextPage?, translations?, paginationMode?, className? }.&#x22;,
  },
}"
/>

## API [#api]

<TypeTable
  type="{
  data: {
    type: &#x22;TEntity[]&#x22;,
    description: &#x22;The current page (or full set) of entities to render.&#x22;,
  },
  columns: {
    type: &#x22;ColumnDef<TEntity, any>[]&#x22;,
    description:
      &#x22;Column defs — from createColumnHelper<TEntity>() (re-exported here) or plain object literals.&#x22;,
  },
  schema: {
    type: &#x22;StandardSchemaV1<unknown, TEntity>&#x22;,
    description:
      &#x22;Optional Standard Schema (Zod v4, Valibot, …); infers TEntity for data/columns end-to-end. Type inference only.&#x22;,
  },
  getRowId: {
    type: &#x22;(row: TEntity, index: number) => string&#x22;,
    description:
      &#x22;Stable row id; defaults to the row index. Required for selection to survive a page change.&#x22;,
  },
  testId: {
    type: &#x22;string&#x22;,
    description:
      &#x22;data-testid on the root Table. See Row/cell/header test ids below and the Testing guide.&#x22;,
  },
  getRowTestId: {
    type: &#x22;(row: TEntity, id: string) => string&#x22;,
    default: &#x22;(row, id) => `row-${id}`&#x22;,
    description:
      &#x22;data-testid for each row, keyed off the resolved row id. Every cell auto-derives `${rowTestId}-cell-${columnId}`, and every header cell gets `header-${columnId}` — no extra config needed either way.&#x22;,
  },
  &#x22;sorting / onSortingChange&#x22;: {
    type: &#x22;SortingState / OnChangeFn<SortingState>&#x22;,
    description: &#x22;Controlled sort state. Omit both for internal state.&#x22;,
  },
  &#x22;filters / onFiltersChange&#x22;: {
    type: &#x22;ColumnFiltersState / OnChangeFn<ColumnFiltersState>&#x22;,
    description:
      &#x22;Controlled column-filter state — react-table's own columnFilters, renamed.&#x22;,
  },
  &#x22;globalFilter / onGlobalFilterChange&#x22;: {
    type: &#x22;string / (value: string) => void&#x22;,
    description:
      &#x22;Controlled global-search value, flattened to a plain string.&#x22;,
  },
  &#x22;pagination / onPaginationChange&#x22;: {
    type: &#x22;PaginationState / OnChangeFn<PaginationState>&#x22;,
    description: &#x22;Controlled pagination state. Default page size 20.&#x22;,
  },
  &#x22;rowSelection / onRowSelectionChange&#x22;: {
    type: &#x22;RowSelectionState / OnChangeFn<RowSelectionState>&#x22;,
    description: &#x22;Controlled row-selection state.&#x22;,
  },
  &#x22;columnVisibility / onColumnVisibilityChange&#x22;: {
    type: &#x22;ColumnVisibilityState / OnChangeFn<ColumnVisibilityState>&#x22;,
    description: &#x22;Controlled column-visibility state.&#x22;,
  },
  &#x22;columnSizing / onColumnSizingChange&#x22;: {
    type: &#x22;ColumnSizingState / OnChangeFn<ColumnSizingState>&#x22;,
    description:
      &#x22;Controlled column-width state, keyed by column id in px. Only ever changes via user drag when enableColumnResizing is set.&#x22;,
  },
  initialState: {
    type: &#x22;Partial<{ sorting, filters, globalFilter, pagination, rowSelection, columnVisibility, columnSizing }>&#x22;,
    description: &#x22;Seeds internal state for any omitted (uncontrolled) slice.&#x22;,
  },
  &#x22;manualSorting / manualFiltering / manualPagination&#x22;: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description:
      &#x22;Flip a slice to server-owned — DataTable renders data as-is for that slice.&#x22;,
  },
  enableMultiSort: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Shift-click multi-column sort.&#x22;,
  },
  rowCount: {
    type: &#x22;number&#x22;,
    description: &#x22;Total server rows; lets react-table derive pageCount.&#x22;,
  },
  pageCount: {
    type: &#x22;number&#x22;,
    description:
      &#x22;Explicit page count when rowCount is unknown. -1 → prev/next-only.&#x22;,
  },
  hasNextPage: {
    type: &#x22;boolean&#x22;,
    description: 'Best-effort &#x22;next&#x22; gate when the total is unknown.',
  },
  pageSizeOptions: {
    type: &#x22;number[]&#x22;,
    default: &#x22;[10, 20, 50]&#x22;,
    description: &#x22;Page-size choices for the pagination Select.&#x22;,
  },
  translations: {
    type: &#x22;DataTableTranslations&#x22;,
    description:
      &#x22;Overrides every piece of DataTable's wording. Pagination footer: rows (the page-size Select's label), items (the row range/total, as a function of { hits, from, to, totalKnown }), previousPage and nextPage. Header controls' accessible names: selectAllRows, selectRow(rowId), columnVisibility, sortColumn(label, mode) (mode is \&#x22;asc\&#x22; | \&#x22;desc\&#x22; | \&#x22;none\&#x22;), filterColumn(label, active), resizeColumn(label). States and filters: clearFilters, retry, min and max (the number filter's placeholders). Stacked layout: tableOptions, tableOptionsActive(count), columns, sortBy, clearSorts, filters. Forwarded to the controls DataTable renders: triggerLabel (the page-size Select's chevron), clearTriggerLabel (the select and number filters' clear buttons), incrementLabel and decrementLabel (the number filter's steppers), closeTriggerLabel (the stacked options drawer's close button). English defaults ship in the kit; a partial override only replaces the keys you set — see Translations above.&#x22;,
  },
  paginationMode: {
    type: '&#x22;page&#x22; | &#x22;rows&#x22;',
    default: '&#x22;page&#x22;',
    description:
      '&#x22;page&#x22; shows the current page number, with the row range/total in a tooltip on hover or focus. &#x22;rows&#x22; mirrors that — the row range/total is always visible (announced via role=&#x22;status&#x22; as it changes), with the page number moved into the tooltip instead.',
  },
  enablePagination: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description:
      &#x22;Shows the pagination footer. Set false to hide it entirely.&#x22;,
  },
  loading: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description:
      &#x22;Skeleton rows on first load; dims existing rows on a refetch.&#x22;,
  },
  error: {
    type: &#x22;ReactNode&#x22;,
    description: 'Replaces the body with a role=&#x22;alert&#x22; region.',
  },
  onRetry: {
    type: &#x22;() => void&#x22;,
    description:
      'Wires the error state\'s &#x22;Retry&#x22; button — a no-op button otherwise.',
  },
  empty: {
    type: &#x22;ReactNode&#x22;,
    default: '&#x22;No results found&#x22;',
    description: &#x22;Shown when !loading && data produced no rows.&#x22;,
  },
  enableRowSelection: {
    type: &#x22;boolean | ((row: Row<TEntity>) => boolean)&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Auto-injects a leading checkbox column.&#x22;,
  },
  onRowClick: {
    type: &#x22;(row: TEntity) => void&#x22;,
    description:
      &#x22;Fires unless the click landed on a nested interactive element — never the row's only affordance.&#x22;,
  },
  title: {
    type: &#x22;ReactNode&#x22;,
    description: &#x22;Rendered via Table.Title in the Toolbar.&#x22;,
  },
  toolbarActions: {
    type: &#x22;ReactNode&#x22;,
    description: &#x22;Rendered at the toolbar's trailing edge.&#x22;,
  },
  enableGlobalFilter: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Shows the debounced global-search Input.&#x22;,
  },
  globalFilterPlaceholder: {
    type: &#x22;string&#x22;,
    description: &#x22;Placeholder for the global-search Input.&#x22;,
  },
  globalFilterDebounce: {
    type: &#x22;number&#x22;,
    default: &#x22;300&#x22;,
    description: &#x22;Debounce (ms) before a keystroke reaches globalFilter.&#x22;,
  },
  enableColumnVisibility: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description: &#x22;Shows the column-visibility Menu (hideable columns only).&#x22;,
  },
  enableColumnResizing: {
    type: &#x22;boolean&#x22;,
    default: &#x22;false&#x22;,
    description:
      &#x22;Shows a drag handle (mouse, touch, keyboard) on each resizable header. Set size/minSize/maxSize or enableResizing: false per column.&#x22;,
  },
  sizing: {
    type: &#x22;'fixed' | 'auto'&#x22;,
    default: &#x22;'fixed'&#x22;,
    description:
      &#x22;'auto' opts back into table-layout: auto content-based sizing — size/minSize and enableColumnResizing have no effect.&#x22;,
  },
  fillLastColumn: {
    type: &#x22;boolean&#x22;,
    default: &#x22;true&#x22;,
    description:
      'sizing=&#x22;fixed&#x22; only: stretches the last column to absorb leftover width instead of leaving a gap. No resize handle on the last column while this is on.',
  },
  size: {
    type: &#x22;'xs' | 'sm' | 'md' | 'lg' | 'xl'&#x22;,
    default: &#x22;'md'&#x22;,
    description: &#x22;Forwarded to Table — density.&#x22;,
  },
  striped: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Forwarded to Table.&#x22;,
  },
  stickyHeader: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Forwarded to Table.&#x22;,
  },
  stickyFooter: {
    type: &#x22;boolean&#x22;,
    description: &#x22;Forwarded to Table.&#x22;,
  },
  responsive: {
    type: &#x22;'scroll' | 'stack'&#x22;,
    default: &#x22;'scroll'&#x22;,
    description:
      'stack renders rows as label:value Cards below the @lg container-query breakpoint. When stacked, a &#x22;Table options&#x22; button opens a bottom Drawer with sort/filter/column-visibility controls in place of the header row.',
  },
  className: {
    type: &#x22;string&#x22;,
  },
  classNames: {
    type: &#x22;{ root, table, head, headerRow, headerCell, body, row, cell, empty, error, toolbar, pagination }&#x22;,
  },
}"
/>
