Data Table
Tables purposefully designed for dynamic, application data with features like sorting, filtering, and pagination. Powered by TanStack Table.
When to use
A DataTable is for dynamic, application data: anywhere users need to sort, filter, paginate, select, or click rows. It is built on top of Table and wires up TanStack Table so you get those behaviors out of the box.
- Prefer
Tablefor static, presentational tabular content (pricing matrices, reference tables, invoice summaries). @ngrok/mantle/data-tablere-exports every TanStack Table export:useTable,tableFeatures,createColumnHelper, the*Featureobjects (rowSortingFeature,rowSelectionFeature,rowExpandingFeature, and the rest), thecreate*RowModelfactories, and thesortFn_*,filterFn_*, andaggregationFn_*functions. You don't need to add@tanstack/react-tableas a separate dependency.
Quick start
The minimum viable DataTable. Copy, paste, replace the type and data:
A fuller example, with sortable columns, row-click navigation, and a sticky action column:
Composition
Compose the parts of a DataTable together to build your own:
Features
TanStack Table v9 ships each behavior as a feature object. Register the ones a table uses with tableFeatures once, at module scope. Pass the result to useTable as features. Sorting, expansion, selection, pagination, filtering, and column hiding are all opt-in; the core row model is always on. A table with no interactive behavior registers none: const features = tableFeatures({}).
The DataTable parts read only what the table registers:
DataTable.HeaderSortButtonandDataTable.RowExpandButtonneed their feature. If you pass them a column or row from a table without it, the prop does not typecheck.DataTable.Row,DataTable.EmptyRow, andDataTable.ExpandedRowwork with or withoutcolumnVisibilityFeature. Without it, they render and span every column.
tableFeatures checks the slots against each other: sortedRowModel needs rowSortingFeature, globalFilteringFeature needs columnFilteringFeature, and so on. A missing prerequisite is a type error.
The sortFns slot is a registry of named comparators. Auto-sort (sortFn: "auto", the default) resolves alphanumeric, text, and datetime by name and uses sortFn_basic for numbers with no entry, so register those three and every column sorts as it did in v8. A column's own sortFn: "name" resolves against the same slot, and a custom comparator joins the same object under its own key. Without a sortFns slot, auto-sort logs a warning in development and falls back to sortFn_basic. TanStack still exports a full sortFns registry, but v9 marks it deprecated, so import the sortFn_* functions you use.
The filterFns and aggregationFns slots work the same way for "auto" and string names, with no fallback. A function value bypasses both slots. A column's filterFn: "auto" (the default) resolves includesString for strings and inNumberRange for numbers against filterFns. With no entry, the column returns every row. The global filter's "auto" default is filterFn_includesString itself, so a search box alone needs no slot. A group row's value for a non-grouping column comes from rowAggregationFeature, whose "auto" default resolves sum for numbers and extent for dates against aggregationFns. Without the feature and the entry, the group row's getValue() is undefined. Both slots warn in development only. The migration guide has the full mapping.
Rules
Follow these invariants for a correctly styled, accessible, and behaving DataTable.
- Type columns with
createColumnHelper<typeof features, TData>(). It threads the registered features andTDatathroughheader,cell, androw.original.props.columnthen carries the sorting API only when the table registersrowSortingFeature. Consumers get inference instead ofunknown. - Wrap the column list in
columnHelper.columns([...]).useTabletypescolumnsasColumnDef<TFeatures, TData, unknown>[]. A column'sheaderandcelltake itsTValueas input, soTValueis contravariant. A bare array ofstringandnumberaccessors therefore doesn't typecheck againstunknown. The helper returns a listuseTableaccepts. Each column keeps its own value type. - Wrap every body cell in
DataTable.Cell. A raw<td>skips the mantle typography, padding, and sticky-column styling. - Wrap every header in
DataTable.Header. For sortable columns, also wrap its contents inDataTable.HeaderSortButtonand pass the samecolumntoDataTable.Header: the button carries the icon and the cycling behavior, and the header cell exposes the sort state througharia-sort. - Key rows with
row.id. TanStack Table tracks row identity across sort/filter/pagination; using array indexes will re-mount rows incorrectly. - Always branch on
rows.length > 0and renderDataTable.EmptyRowfor the empty case. The empty row spans all columns and preserves the table's frame; returningnullleaves an empty<tbody>and collapses the frame. - Place an action column last, using
columnHelper.display({ ... }). PairDataTable.ActionHeader(inheader) withDataTable.ActionCell(incell) so the pinned column aligns across header and body when scrolling horizontally. - Pass
onClicktoDataTable.Rowfor row-click behavior. The row auto-appliescursor-pointerwhenonClickis set; do not add it yourself. Override with anothercursor-*class (for example,cursor-wait) viaclassNameif needed. - Stop click propagation inside
DataTable.ActionCellwhen the row is clickable. Without it, clicks on dropdown triggers, buttons, and links inside the action cell will bubble and fire the rowonClick. - Add a keyboard-accessible equivalent for row navigation. A
<tr>is not focusable and is not announced as interactive to assistive tech. If clicking a row navigates, render a<Link>in the primary cell so keyboard and screen-reader users have a reachable equivalent. - Register features, not row models.
useTablewires up only whattableFeaturesregisters:rowSortingFeaturewithsortedRowModelfor sorting,rowPaginationFeaturewithpaginatedRowModelfor pagination,columnFilteringFeaturewithfilteredRowModelfor filtering. A feature without its row model is a dead control:rowSortingFeaturealone toggles the sort icon and never reorders a row. - Give a compiled component of your own its subscription. mantle ships
DataTablecompiled with the React Compiler, and each part subscribes to the state it reads. If your app also compiles, and one of your components receives only a stablerow,column,cell, orheaderand reads state through it, wrap that read in TanStack'sSubscribe. See TanStack's React Compiler guide.
Anti-patterns
Common mistakes. The left column is what not to do; the right column is the fix.
Recipes
Empty states (no data vs. no results)
A list has two distinct empty states, and they need different copy and actions:
- No data: nothing exists yet. Informational, optionally a primary "create" action.
- No results for the active filter/search: the user filtered to nothing and needs a way out (a Clear filters button).
Branch on rows.length, then on whether a filter is active, and host an Empty inside DataTable.EmptyRow for each branch. A pending query is a third state that comes before both: the List Page Loading States recipe wires all three, with skeleton rows that match the loaded rows. EmptyRow already spans every column (auto colSpan) and Empty.Root centers itself. Drop a single Empty.Root in as the child; don't hand-roll a <td> or any centering. Type into the filter below to see the "no results" branch and its working Clear filters reset:
With no data at all, the table shows the first branch:
Navigating on row click
Pass onClick to DataTable.Row and navigate with React Router's href() + useNavigate(). Render a <Link> inside the primary cell for keyboard and screen-reader users; the row onClick is a larger pointer target on top. Wrap the link in SandboxedOnClick with asChild and allowClickEventDefault, so a click on the link navigates once and doesn't also run the row handler. Put a row's own controls, such as a selection checkbox or an expand toggle, in a DataTable.ControlCell. No click in that cell reaches the row handler.
The row runs onClick only for a plain primary click. A modified click (⌘, Ctrl, Shift, Alt), a non-primary button, and a click that ends a text selection inside the row all skip it. The link answers a modified click with a new tab, and a selection copies text, so neither is a request to navigate. The row keeps its structural role="row", which is what makes a <tr onClick> around a link safe: the row has no widget role, so the link isn't nested interactive content. Don't add tabIndex or role="button" to the row, and don't reach for role="grid", which commits the table to roving focus and arrow-key navigation.
Action column with a dropdown menu
Define the action column with columnHelper.display and pair DataTable.ActionHeader with DataTable.ActionCell. The cell stops its own clicks, so the menu opens without a clickable row navigating. The table below is wider than the page, so it scrolls; the action column stays on the right edge.
Customizing cells (badges, numeric, truncation)
Cell rendering is plain React. Use Badge for status pills, text-right for numeric columns, and truncate max-w-* for long strings.
Sizing a column to its content
The table uses the browser's automatic layout, so every column gets a share of the spare width. A short value such as a timestamp then sits in a wide column, far from the next value. Set meta: { width: "fit" } on the column, and pass column to its DataTable.Header and to every DataTable.Cell. The column is then only as wide as its widest value, on one line, and the spare width goes to the other columns.
Pass column to the header and to each cell of the column. A part with no column cannot read meta, so a header without it keeps its share of the spare width, and a cell without it can wrap.
TanStack Table types meta through the global ColumnMeta interface, and mantle adds width to it. A columnMeta slot on tableFeatures replaces that interface for its table. If your table has a slot, add DataTableColumnMeta to the slot type:
Pagination controls
Register rowPaginationFeature with paginatedRowModel: createPaginatedRowModel(), then wire CursorPagination to the table instance for a page-size dropdown plus previous/next buttons: no hand-rolled Buttons or Page X of Y span needed.
Rules:
- The page size must be one of
CursorPagination.PageSizeSelect'spageSizes(default5 | 10 | 20 | 50 | 100), or the select throws. - The table owns the page size: pass the live
table.state.pagination.pageSizeaspageSize. The select then follows asetPageSize()call from anywhere, including table state you keep in the URL. - Reset to page 0 on a size change.
- Server/cursor data (no
rowPaginationFeature): wireButtonsto your fetch's has-next/has-prev + load callbacks, and use the read-onlyCursorPagination.PageSizeValuewhen the page size is fixed server-side rather thanPageSizeSelect. See Pagination → Within a data table.
Row selection with checkboxes
Add a columnHelper.display checkbox column: the header checkbox toggles every row (and goes indeterminate when only some are selected), each cell checkbox toggles its row. Register rowSelectionFeature, drive rowSelection through useTable, and read it back from the table instance. mantle's Checkbox is a native input, so handle changes with onChange + event.target.checked (not onCheckedChange), and resolve the header's tri-state with the exported selectAllChecked helper.
Put each row checkbox in a DataTable.ControlCell. A click on the cell's padding toggles the checkbox, so the whole cell is the target. No click in the cell reaches a clickable row, so a click that misses the checkbox doesn't navigate. Give each checkbox a label that names its row, such as Select payment 3u1reuv4. A screen reader reads the label out of the row's context, so Select row alone doesn't say which row.
0 of 5 selected
Expandable rows
Expand a row into an inline detail panel with a +/− toggle, ideal for inspecting a row's underlying record without leaving the table. Expansion is driven entirely by TanStack Table's expansion state (mantle does not invent its own). Register rowExpandingFeature with expandedRowModel: createExpandedRowModel(), and pass getRowCanExpand: () => true to useTable (required: without sub-rows, no row is expandable by default). A stable getRowId keeps both the expansion keys and the toggle's aria-controls association stable.
Three parts cover the toggle: DataTable.ExpandHeader (the toggle column header), DataTable.ControlCell (the toggle's cell), and DataTable.RowExpandButton (the accessible +/− toggle). A click on the cell's padding toggles the row, and no click in the cell reaches a clickable row. For the detail panel itself, pass renderExpanded to DataTable.Row: when the row is expanded it renders its data row plus a sibling detail row holding the returned content, so you never hand-write the extra <tr> or the expand conditional. renderExpanded is called lazily (only while the row is open), so collapsed rows never build their panel. The detail row spans every visible column and sits on an opaque surface, so it coexists with a sticky action column. When DataTable.RowExpandButton opens a row, its new rows fade in and the rows below slide down over 200 ms with a strong ease-out. When it closes a row, those rows fade out while the rows below slide up over them, on the same curve. A click during the close reverses it. When the user prefers reduced motion, rows open and close at once. A change that doesn't go through the button, such as table.toggleAllRowsExpanded(), has no motion.
The detail panel below renders each row's object as JSON via CodeBlock, highlighted with jsonCodeBlockValue. It runs entirely on the client, with no Shiki runtime shipped to the browser, no build-time plugin, and no server roundtrip. Multi-line objects and arrays are collapsible by default, just like every other JSON code block (pass { foldable: false } for a flat panel).
For full control over the detail row (a custom colSpan, multiple panels, or bespoke markup), omit renderExpanded and render DataTable.ExpandedRow yourself, directly after DataTable.Row and wrapped in a Fragment (never a DOM element, since a node between <tbody> and <tr> is invalid HTML):
Sub-rows
Show child rows below a parent row, such as the replicas of a deployment. Pass getSubRows to useTable to tell TanStack where each row's children are. The expanded row model puts each child of an expanded row in table.getRowModel().rows, directly after its parent, so DataTable.Row renders it with no extra markup. A row with sub-rows can expand by default, so you don't need getRowCanExpand.
Each row has two actions: the caret expands it, and a click anywhere else opens its page.
- Put the caret in a
DataTable.ControlCell. A click on the cell's padding expands the row, and no click in the cell reaches the row'sonClick. A click that misses the caret expands the row instead of navigating. The caret aligns with the first line of a two-line cell. - Pass
onClicktoDataTable.Rowto open the row's page, as in Navigating on row click. Render aLinkin the primary cell, wrapped inSandboxedOnClick, for keyboard and screen-reader users. - A leaf row's
DataTable.RowExpandButtonrenders nothing, and its emptyDataTable.ControlCellpasses clicks to the row, so the leaf row opens its own page.
For a row with sub-rows, the toggle's accessible label is Expand … / Collapse …, and it sets aria-expanded. It sets aria-controls only when the row also renders a detail panel through renderExpanded. The sub-rows open and close with the same motion as a detail panel. Pass size="xs" with expandIcon and collapseIcon for a caret. The xs button is 24 × 24 px, the WCAG 2.2 minimum target size. The demo puts the row's id in the query string instead of opening a page.
Click a row to open it.
API Reference
The DataTable components are built on top of TanStack Table. @ngrok/mantle/data-table re-exports every TanStack Table export (useTable, tableFeatures, createColumnHelper, the *Feature objects, the create*RowModel factories, the sortFn_*, filterFn_*, and aggregationFn_* functions, and the types). The parts that take a table, column, or row prop are generic over TFeatures, the type of the tableFeatures object the table registers. The props infer it from the instance you pass, so you never write it by hand.
DataTable.Root
The root container for the data table. Wraps all other DataTable sub-components and provides the table context. Delegates rendering to Table.Root.
DataTable.Head
Automatically renders column headers from the table instance by iterating table.getHeaderGroups(). Does not accept children — the headers come from each column's header definition.
DataTable.Body
The <tbody> container for rows of data. Typically wraps a map of DataTable.Row or a fallback DataTable.EmptyRow.
DataTable.Row
Renders a single body row using the column definitions from the table instance. Does not accept children — cells come from each column's cell definition.
When onClick is set, the row receives cursor-pointer and data-clickable. Pass a different cursor-* class via className (for example, cursor-wait) to override the cursor. The row runs onClick only for a plain primary click: a modified click, a non-primary button, and a click that ends a text selection inside the row all skip it. A <tr> isn't focusable, so also render a <Link> inside the primary cell as the keyboard and screen-reader path; see Navigating on row click.
The row works with any features. It renders the visible cells when the table registers columnVisibilityFeature, and every cell otherwise. renderExpanded and data-expanded need rowExpandingFeature.
The row subscribes to the whole table state and to the table options. It re-renders its cells when any state slice changes or when useTable receives new options, so a column's cell renderer can read state through row or cell (for example, row.getIsSelected()) and close over your component's values.
Remaining props forward to the HTML <tr> element.
DataTable.EmptyRow
An empty-state row that spans every visible column. Render this as the else branch when rows.length === 0 to keep the table's frame intact. Without columnVisibilityFeature, it spans every column.
DataTable.Header
A <th> cell optimized for header actions. Wrap sortable headers' contents in DataTable.HeaderSortButton and pass the same column here, so the cell carries aria-sort while the column is sorted.
Data attributes
DataTable.HeaderSortButton
A sortable button toggle for column headers. Clicking cycles through sort directions: unsorted → asc → desc → unsorted for "alphanumeric", and unsorted → desc → asc → unsorted (newest-first) for "time". Renders a sort icon that reflects the current direction. The button's accessible name is the column label and does not change with the sort state. When the column cannot sort, the part renders the label as plain text with no button.
The table must register rowSortingFeature; the column prop rejects a column from a table without it at compile time. Pair it with a sortedRowModel, or the button toggles the icon and never reorders a row (see Anti-patterns). Register a sortFns slot too: without one, auto-sort falls back to sortFn_basic.
All additional props from Button. Unlike Button itself, appearance and intent are optional here — the wrapper defaults them to "ghost" and "neutral".
DataTable.Cell
A <td> for one data cell. Applies mantle typography, padding, and alignment from Table.Cell. Pass column to apply the column's meta.width, and pass the same column to the column's DataTable.Header.
Data attributes
DataTable.ActionHeader
A sticky-right <th> that pairs with DataTable.ActionCell. Use as the header for your action column so the pinned column stays aligned across the header and every body row when the table scrolls horizontally. Automatically opts out of stickiness when the table is empty so the scroll-fade shows correctly. Renders a screen-reader-only "Actions" label by default, so the column has a name while it stays visually empty.
children render inside a <span data-slot="data-table-action-header-label">. The indicator and the fallback sr-only label both sit beside children, so without the span a bare text child is never a lone child: a browser translation engine reparents that text node, and the removal React runs when the content changes shape or goes away throws. The span is display: contents, so it adds no box. Browser translation covers the mechanism and the rules your own components need.
Data attributes
DataTable.ActionCell
A sticky-right <td> for per-row action buttons (typically an IconButton that opens a DropdownMenu).
A click inside the cell never reaches a clickable row: the cell stops propagation and keeps the click's default action, so an action menu opens without the row navigating. Your own onClick still runs, and the <td> keeps its role="cell".
children render inside a <span data-slot="data-table-action-cell-label">. The sticky-column indicator is a permanent element sibling, so without the span a bare text child is never a lone child: a browser translation engine reparents that text node, and the removal React runs when the content changes shape or goes away throws. The span is display: contents, so it adds no box. Browser translation covers the mechanism and the rules your own components need.
Data attributes
DataTable.ControlCell
A <td> for a row's own control: a DataTable.RowExpandButton or a selection Checkbox. The whole cell is the control's pointer target, and no click in the cell runs a clickable row's onClick.
- A click on the cell's padding focuses and clicks the control. A click that misses a small control toggles it instead of navigating.
- When the cell holds no enabled control (a leaf row's
RowExpandButtonrenders nothing, or the checkbox is disabled), the click reaches the row. - The cell shows a pointer cursor only while it holds an enabled control. An empty cell keeps the row's cursor.
- The control aligns with the first line of text, so it stays beside a two-line primary cell. On a one-line row, it is centered.
The padding click is for pointer users only. The cell keeps its role="cell" and gets no tabIndex, so keyboard and screen-reader users reach the control itself. Don't add a role or tabIndex to the cell.
Data attributes
DataTable.ExpandHeader
A narrow <th> for the leading expand-toggle column, mirroring DataTable.ActionHeader. Renders a screen-reader-only label by default so the column is announced while staying visually empty.
DataTable.RowExpandButton
An accessible +/− toggle that expands or collapses a row's detail panel or its sub-rows. Place it inside a DataTable.ControlCell in a leading columnHelper.display column. For a detail panel, pair it with DataTable.ExpandedRow. Renders a real <button> that sets aria-expanded and (while a detail panel is open) aria-controls, stops click propagation so it never fires a row-level onClick, and renders nothing when row.getCanExpand() is false. Forwards IconButton props — unlike IconButton itself, appearance and intent are optional here, defaulting to "ghost" and "neutral".
The table must register rowExpandingFeature; the row prop rejects a row from a table without it at compile time. Add expandedRowModel: createExpandedRowModel() when rows have sub-rows; a detail panel expands without it.
DataTable.ExpandedRow
The sibling <tr> that renders a row's expanded detail panel. Render it directly after DataTable.Row, wrapped in a Fragment (never a DOM element), and only when row.getIsExpanded() is true. The single cell spans every visible column, carries the id that DataTable.RowExpandButton targets via aria-controls, and sits on an opaque card surface so it coexists with a sticky action column. Exposes data-expanded-content for styling. The part works with any features; without columnVisibilityFeature, it spans every column.