Kanzo UI
Data display

Data Table

A thin composable layer over TanStack Table — the parts a connected table is assembled from.

Two halves of one pair.

  • Table is markup only — semantic elements, tokenised chrome, no data layer, in the root barrel. You bring the rows and render them.
  • The parts hereuseDataTable plus DataTableRoot, DataTableToolbar, DataTableSearch, DataTableFacetFilter, DataTableViewOptions, DataTableContent and DataTablePagination — are that same table wired to TanStack Table: sorting, filtering, row selection, column visibility and paging.

That split is the engine rule. TanStack Table is a real dependency, which is why this half sits on its own entry point:

import { DataTableRoot } from "@kanzo-tech/ui/table";

@tanstack/react-table is an optional peer, so import { Button } keeps working for everyone who has not installed it.

ContractRegionReward
Something is eating the bell-ropesThornmarch16
A wyrm under the granaryThornmarch33
Nine goats, one roadThornmarch5
The miller's second cellarThornmarch13
The orchard is counting wrongThornmarch15

Usage

Everything on this page lives on the @kanzo-tech/ui/table subpath, not the root barrel. Keeping it off the root means import { Button } from "@kanzo-tech/ui" never pulls TanStack into the bundle — only the apps that actually render a data table pay for it.

pnpm add @tanstack/react-table
import {
  type ColumnDef,
  DataTableContent,
  DataTablePagination,
  DataTableRoot,
  DataTableSearch,
  DataTableToolbar,
  sortableHeader,
  useDataTable,
} from "@kanzo-tech/ui/table";
const table = useDataTable({ columns, data });

<DataTableRoot table={table}>
  <DataTableToolbar>
    <DataTableSearch column="name" />
  </DataTableToolbar>
  <DataTableContent />
  <DataTablePagination />
</DataTableRoot>

Six lines, and every one of them is a decision you can change. There is deliberately no <DataTable columns data /> preset over the top: a fixed arrangement of parts that already ship is an example, not a component, and the first call site that wants a seventh thing drops to the parts anyway. example-complete below is that arrangement, in a file you can copy.

ColumnDef, Row, CellContext and Table are re-exported from the same subpath, so a consumer types its columns without a direct TanStack import.

Sorting

Sorting is opt-in per column. TanStack marks every column sortable by default, but a column only becomes clickable when its header is built with sortableHeader — a plain-string header stays inert.

Region
The tide brought back the wrong boat41Saltmere
Whatever is in the salt-house, it is not salt41Saltmere
The lighthouse keeper has stopped answering78Saltmere
Three fords, one stoneback38Saltmere
Harpies above the cliff road75Saltmere
Survey the drowned quarter before the spring tide18Saltmere
Do not let the assessor drink the sample7Saltmere
Bog-hounds, in threes, along the causeway36Duskfen

DataTableSearch filters on the single column id you give it, and DataTableToolbar is what puts it and any actions on one row. Render neither and there is no toolbar. DataTablePagination hides itself whenever there is one page, no selection and no pageSizes.

Empty state

DataTableContent's empty prop takes whatever should stand in for the rows. An Item composition is the usual shape.

ContractReward
Nothing on the board

Every contract in Greenhollow has been claimed. Try another region.

Give a column a footer and the table renders a <tfoot> — totals, counts, a summary row. Columns without one leave an empty footer cell; if no column defines a footer, no <tfoot> is emitted at all.

ContractRewardRegion
Something is eating the bell-ropes16Thornmarch
A wyrm under the granary33Thornmarch
Nine goats, one road5Thornmarch
The miller's second cellar13Thornmarch
The orchard is counting wrong15Thornmarch
Escort the assessor as far as the ford6Thornmarch
A stoneback has taken the lower ford30Thornmarch
Audit the Ash & Co. expense ledger6Thornmarch
The children say the well talks32Greenhollow
Mimics in the tithe-barn15Greenhollow
Bog-hounds took the herd dog15Greenhollow
Grimalkin, but only at dusk14Greenhollow
Posted200 gold

The parts

useDataTable owns the engine — every row model wired, the filter/sort/visibility/selection slices held as state, manualPagination and friends passed straight through. It returns the TanStack Table instance and nothing else, so anything the TanStack API can do is still on the table. The parts own the chrome and read that instance off React context.

Region
The tide brought back the wrong boat41Saltmere
Whatever is in the salt-house, it is not salt41Saltmere
The lighthouse keeper has stopped answering78Saltmere
Three fords, one stoneback38Saltmere

Anatomy

const table = useDataTable({ data, columns })

<DataTableRoot table={table}>
├── <DataTableToolbar>
│   ├── <DataTableSearch />
│   ├── <DataTableFacetFilter />
│   └── <DataTableViewOptions />
├── <DataTableContent />
└── <DataTablePagination />

Every part is optional and their order is yours — the toolbar is a plain flex row, so your own buttons go in it alongside ours.

DataTableSearch with a column filters that one column; without it, the table's global filter.

Row selection

selectColumn() returns a ready-made leading checkbox column: the header reflects a partially selected page as indeterminate, both checkboxes carry a real aria-label (the column has no header text to borrow), and the cell stops click propagation so it stays inert under onRowClick. It is excluded from sorting and from the View menu.

Read the selection off the table instance — getFilteredSelectedRowModel().rows counts only rows that survive the current filters, which is what a bulk action should operate on.

ContractRewardRegion
Something is eating the bell-ropes16Thornmarch
A stoneback has taken the lower ford30Thornmarch
Audit the Ash & Co. expense ledger6Thornmarch
The tide brought back the wrong boat41Saltmere

DataTablePagination reports the selection count on its own, which is why it appears here even though a single page fits.

Column visibility

ContractRewardRegion
Something is eating the bell-ropes16Thornmarch
A wyrm under the granary33Thornmarch
Nine goats, one road5Thornmarch
The miller's second cellar13Thornmarch
The orchard is counting wrong15Thornmarch
Escort the assessor as far as the ford6Thornmarch
A stoneback has taken the lower ford30Thornmarch
Audit the Ash & Co. expense ledger6Thornmarch
The children say the well talks32Greenhollow
Mimics in the tithe-barn15Greenhollow
Bog-hounds took the herd dog15Greenhollow
Grimalkin, but only at dusk14Greenhollow

DataTableViewOptions lists every column whose getCanHide() is true, labelling each by its header when that header is a string. A column whose header is sortableHeader("Reward") is a render function, not a label, so the menu falls back to the column id. Keep the plain string header, give the column an explicit id you are happy to show, or take it out of the menu with enableHiding: false.

Set enableHiding: false on the column that identifies the row: a table whose name column can be hidden is a table of anonymous rows. Initial state comes from initialColumnVisibility on the hook.

Faceted filters

DataTableFacetFilter is a multi-select over a column's faceted values, with live counts. Leave options out and it offers every distinct value it finds; pass them to narrow the list, relabel a raw value or give it an icon. It is a FacetFilter — a popover over a listbox, because a filter is a value and not a command — so the rows are ordered by label whichever way they arrive.

searchable adds a filter field above the values, for a column with more of them than anyone reads. Search is honest and total here: the table already holds its rows, so the field narrows every value the column has, rather than a page of them the way a chart's facet filter does. It is a prop and not a count threshold — a control that grows a field once the facet passes n values reshapes itself under the cursor.

A faceted column must declare filterFn: facetFilterFn.

TanStack's built-in arrIncludesSome expects the cell to be the array. On a scalar cell it degrades to substring matching, so filtering by "active" silently keeps "inactive" too. facetFilterFn compares by equality against the selected values, which is what the filter means.

import { facetFilterFn } from "@kanzo-tech/ui/table";

{ accessorKey: "status", header: "Status", filterFn: facetFilterFn }
ContractStatePosted by
The children say the well talksClaimedAmber Hall
Mimics in the tithe-barnOpenAmber Hall
Map the drowned laneFailedLanternwood
Second attempt: the drowned laneOpenLanternwood
Bog-hounds took the herd dogClaimedAmber Hall
The hedge has moved eleven feetOpenLanternwood
Grimalkin, but only at duskSettledAmber Hall

Selecting nothing clears the column filter entirely rather than filtering to the empty set, and the popover stays open across selections — a filter is a set, not a single choice.

Pagination

DataTablePagination retires itself when there is nothing to say: one page, no selection and no size selector means no row of empty chrome. Pass pageSizes for a rows-per-page selector, which keeps the row alive even on a single page.

RefContractDue
Q-1041Something is eating the bell-ropes1312-10-15
Q-1042A wyrm under the granary1312-05-29
Q-1043Nine goats, one road1312-07-24
Q-1044The miller's second cellar1312-05-18
Q-1045The orchard is counting wrong1312-06-17
Rows per page

Server-side pagination and sorting

Hand useDataTable any TanStack option and it goes straight to useReactTable, so the server-driven modes work as documented: manualPagination with a rowCount tells the table how many rows exist beyond the ones you handed it, and the matching row model steps aside. manualSorting and manualFiltering behave the same way.

Read what to fetch off the table instance — table.getState().pagination and .sorting — rather than through onPaginationChange, which hands you a TanStack Updater (a value or a function) and leaves you to resolve it against the previous state yourself.

ContractPosted
Reading the board…

Everything together

Selection, search, two faceted filters, column visibility, sortable headers, row activation and a page-size selector.

RegionStatePosted byDue
Something is eating the bell-ropes16ThornmarchOpenAmber Hall1312-10-15
A wyrm under the granary33ThornmarchFailedAmber Hall1312-05-29
Nine goats, one road5ThornmarchSettledAmber Hall1312-07-24
The miller's second cellar13ThornmarchSettledAmber Hall1312-05-18
The orchard is counting wrong15ThornmarchSettledAmber Hall1312-06-17
Rows per page

Click a row to open the contract.

Your own parts

useDataTableContext() returns the table instance of the enclosing DataTableRoot, typed by your row type, so a custom toolbar control is a component like any other:

function ResetFilters() {
  const table = useDataTableContext<Quest>();
  if (!table.getState().columnFilters.length) return null;

  return (
    <Button onClick={() => table.resetColumnFilters()} size="sm" variant="ghost">
      Reset
    </Button>
  );
}

The context value is not memoised, and that is deliberate.

TanStack keeps a single mutable table instance, so { table } never changes identity on its own. Wrapping it in useMemo — the reflex an optimisation pass reaches for — freezes any part passed down as children, whose element reference React reuses, on the state it first rendered with. Nothing throws; the table just stops reacting to filters and paging.

DataTableRoot therefore allocates a fresh context object on every render. Leave it alone.

API Reference

useDataTable

Returns a TanStack Table<TData>.

OptionTypeDefault
columnsColumnDef<TData, TValue>[]
dataTData[]
pageSizenumber20
initialSortingSortingState[]
initialColumnFiltersColumnFiltersState[]
initialColumnVisibilityVisibilityState{}
initialRowSelectionRowSelectionState{}
initialGlobalFilterstring""

Every other TableOptions key — manualPagination, rowCount, getRowId, enableRowSelection, onSortingChange, … — is forwarded untouched. Supplied onXChange handlers are called in addition to the hook's own state, never instead of it.

DataTableRoot

PropTypeDefault
tableTable<TData>

Extends ark.div. useDataTableContext<TData>() reads the instance back out.

DataTableSearch

PropTypeDefault
columnstring— (global filter)
placeholderstring"Search…"

className sizes the input group — the visible box; every other prop lands on the input.

DataTableFacetFilter

PropTypeDefault
columnstring
labelReactNodethe column id
optionsDataTableFacetOption[]every faceted value
searchablebooleanfalse

DataTableFacetOption is { value: string; label?: ReactNode; icon?: ComponentType }.

DataTableViewOptions

PropTypeDefault
labelReactNode"View"

DataTableContent

PropTypeDefault
emptyReactNode"No results."
onRowClick(row: TData) => void
stickyHeaderbooleanfalse
maxHeightstring | number

className styles the bordered box; the remaining props reach the <table>, so variant and isHoverable from Table pass through. With onRowClick the row becomes tabbable and Enter/Space activate it — a <tr> is not an interactive role, so it has to be made one. Activation is skipped when the key lands on a control inside the row.

Pinning the header

stickyHeader keeps the column names in place while the surrounding region scrolls. It is not just a class on the cells, because two boxes in between are scroll containers and position: sticky resolves against the nearest one: the bordered box (overflow-hidden) and Table's own wrapper (overflow-auto). Both are exactly as tall as their content, so a sticky th inside them pins to something that never moves. The prop stands both down — the box to overflow-clip, which still clips the rounded corners without scrolling — and the header then pins to whatever really scrolls: a ShellMain, a dialog body, the page.

Which scroll container it pins to is what maxHeight decides, and the two answers behave differently enough to pick between deliberately.

stickyHeader alonestickyHeader + maxHeight
Pins tothe enclosing regionthe table's own scrollport
Scrollbarsone — the region'stwo, once the rows exceed the height
Table wider than its boxunreachablescrolls sideways

Set maxHeight whenever the columns may not fit, which on a narrow viewport is most tables. Pinning to the enclosing region stands the wrapper's overflow-auto down, and that is the same declaration that let a wide table scroll sideways — CSS cannot keep one axis scrollable while the other stays sticky. Without a height, columns past the right edge have nothing to scroll them and simply cannot be read. The pinned cells are painted bg-background; on another surface pass className="bg-card" to your TableHead.

DataTablePagination

PropTypeDefault
pageSizesnumber[]— (no selector)

Extends ark.div. Renders nothing when there is one page, no selection and no pageSizes.

selectColumn

selectColumn<TData>(options?): ColumnDef<TData, unknown>

OptionTypeDefault
idstring"select"
headerLabelstring"Select all rows on this page"
rowLabel(row: Row<TData>) => string`Select row ${n}`

sortableHeader

sortableHeader(label: ReactNode) returns a TanStack header renderer — use it as a column's header.

facetFilterFn

A column filterFn that keeps rows whose value is one of the selected facets. Required on any column driven by DataTableFacetFilter.

Every part's props are exported as an interface — DataTableContentProps, DataTableFacetFilterProps, DataTablePaginationProps, DataTableRootProps, DataTableSearchProps and DataTableViewOptionsProps — so a wrapper can take the same props without restating them.

SelectColumnOptions is exported — the third argument to the checkbox column, carrying id, headerLabel and a rowLabel(row) for the per-row accessible name. It is generic in the row type, so a column built for your data keeps it.

On this page