Skip to main content

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 Table for static, presentational tabular content (pricing matrices, reference tables, invoice summaries).
  • @ngrok/mantle/data-table re-exports every TanStack Table export: useTable, tableFeatures, createColumnHelper, the *Feature objects (rowSortingFeature, rowSelectionFeature, rowExpandingFeature, and the rest), the create*RowModel factories, and the sortFn_*, filterFn_*, and aggregationFn_* functions. You don't need to add @tanstack/react-table as a separate dependency.

Quick start

The minimum viable DataTable. Copy, paste, replace the type and data:

import {	DataTable,	createColumnHelper,	createSortedRowModel,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table"; type Row = { id: string; name: string }; // Register only the features the table uses. Auto-sort resolves the// `alphanumeric`, `text`, and `datetime` comparators by name, so register those three.const features = tableFeatures({	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); const columnHelper = createColumnHelper<typeof features, Row>(); const columns = columnHelper.columns([	columnHelper.accessor("name", {		id: "name",		header: (props) => (			<DataTable.Header column={props.column}>				<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">					Name				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),]); function MyTable({ data }: { data: Row[] }) {	const table = useTable({ features, data, columns });	const rows = table.getRowModel().rows; 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{rows.length > 0 ? (					rows.map((row) => <DataTable.Row key={row.id} row={row} />)				) : (					<DataTable.EmptyRow>No results.</DataTable.EmptyRow>				)}			</DataTable.Body>		</DataTable.Root>	);}

A fuller example, with sortable columns, row-click navigation, and a sticky action column:

Actions
3u1reuv4242successAbe45@example.com
bhqecj4p721failedcarmella@example.com
m5gr84i9316successken99@example.com
derv1ws0837processingMonserrat44@example.com
5kma53ae874successSilas22@example.com
import { IconButton } from "@ngrok/mantle/button";import {	DataTable,	createColumnHelper,	createSortedRowModel,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { Empty } from "@ngrok/mantle/empty";import { Icon } from "@ngrok/mantle/icon";import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree";import { PencilSimpleIcon } from "@phosphor-icons/react/PencilSimple";import { TrashIcon } from "@phosphor-icons/react/Trash";import { TrayIcon } from "@phosphor-icons/react/Tray";import { href, useNavigate } from "react-router"; type Payment = {	id: string;	amount: number;	status: "pending" | "processing" | "success" | "failed";	email: string;}; const features = tableFeatures({	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.accessor("id", {		id: "id",		header: (props) => (			<DataTable.Header column={props.column}>				<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">					ID				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("amount", {		id: "amount",		header: (props) => (			<DataTable.Header className="w-50" column={props.column}>				<DataTable.HeaderSortButton					className="justify-end"					column={props.column}					iconPlacement="start"					sortingMode="alphanumeric"				>					Amount				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => <DataTable.Cell className="text-right">{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("status", {		id: "status",		header: (props) => (			<DataTable.Header column={props.column}>				<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">					Status				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("email", {		id: "email",		header: (props) => (			<DataTable.Header column={props.column}>				<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">					Email				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.display({		id: "actions",		header: () => <DataTable.ActionHeader />,		cell: () => (			<DataTable.ActionCell>				<DropdownMenu.Root>					<DropdownMenu.Trigger asChild>						<IconButton							appearance="ghost"							intent="neutral"							className="rounded"							type="button"							size="sm"							label="Open actions"							icon={<DotsThreeIcon weight="bold" />}						/>					</DropdownMenu.Trigger>					<DropdownMenu.Content align="end">						<DropdownMenu.Item className="flex items-center gap-2">							<Icon svg={<PencilSimpleIcon />} /> Edit						</DropdownMenu.Item>						<DropdownMenu.Item className="text-danger-600 flex items-center gap-2">							<Icon svg={<TrashIcon />} />							Delete						</DropdownMenu.Item>					</DropdownMenu.Content>				</DropdownMenu.Root>			</DataTable.ActionCell>		),	}),]); function PaymentsTable({ data }: { data: Payment[] }) {	const navigate = useNavigate();	const table = useTable({		features,		data,		columns,		initialState: { sorting: [{ id: "email", desc: false }] },	});	const rows = table.getRowModel().rows; 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{rows.length > 0 ? (					rows.map((row) => (						<DataTable.Row							key={row.id}							onClick={() => {								navigate(href("/payments/:id", { id: row.original.id }));							}}							row={row}						/>					))				) : (					<DataTable.EmptyRow>						<Empty.Root>							<Empty.Icon svg={<TrayIcon />} />							<Empty.Title>No payments yet</Empty.Title>							<Empty.Description>								<p>Payments you receive will appear here.</p>							</Empty.Description>						</Empty.Root>					</DataTable.EmptyRow>				)}			</DataTable.Body>		</DataTable.Root>	);}

Composition

Compose the parts of a DataTable together to build your own:

DataTable.Root├── DataTable.Head│   └── Table.Row│       ├── DataTable.ExpandHeader│       ├── DataTable.Header│       │   └── DataTable.HeaderSortButton│       └── DataTable.ActionHeader└── DataTable.Body    ├── DataTable.Row    │   ├── DataTable.ControlCell    │   │   └── DataTable.RowExpandButton    │   ├── DataTable.Cell    │   └── DataTable.ActionCell    ├── DataTable.ExpandedRow    └── DataTable.EmptyRow

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.HeaderSortButton and DataTable.RowExpandButton need their feature. If you pass them a column or row from a table without it, the prop does not typecheck.
  • DataTable.Row, DataTable.EmptyRow, and DataTable.ExpandedRow work with or without columnVisibilityFeature. Without it, they render and span every column.
Part or behaviorRegister in tableFeatures({ … })
DataTable.HeaderSortButtonrowSortingFeature, sortedRowModel: createSortedRowModel(), a sortFns registry
Row expansion (DataTable.RowExpandButton, renderExpanded)rowExpandingFeature, plus getRowCanExpand on useTable; for sub-rows, getSubRows and expandedRowModel: createExpandedRowModel()
Column hiding (columnVisibility state)columnVisibilityFeature
Row selection (rowSelection state)rowSelectionFeature
PaginationrowPaginationFeature, paginatedRowModel: createPaginatedRowModel()
FilteringcolumnFilteringFeature, filteredRowModel: createFilteredRowModel(), a filterFns registry for column filters that use "auto" or a string name; add globalFilteringFeature for a global search
GroupingcolumnGroupingFeature, groupedRowModel: createGroupedRowModel(); add rowAggregationFeature for totals on group rows, and an aggregationFns registry when those columns use "auto" or a string name

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.

  1. Type columns with createColumnHelper<typeof features, TData>(). It threads the registered features and TData through header, cell, and row.original. props.column then carries the sorting API only when the table registers rowSortingFeature. Consumers get inference instead of unknown.
  2. Wrap the column list in columnHelper.columns([...]). useTable types columns as ColumnDef<TFeatures, TData, unknown>[]. A column's header and cell take its TValue as input, so TValue is contravariant. A bare array of string and number accessors therefore doesn't typecheck against unknown. The helper returns a list useTable accepts. Each column keeps its own value type.
  3. Wrap every body cell in DataTable.Cell. A raw <td> skips the mantle typography, padding, and sticky-column styling.
  4. Wrap every header in DataTable.Header. For sortable columns, also wrap its contents in DataTable.HeaderSortButton and pass the same column to DataTable.Header: the button carries the icon and the cycling behavior, and the header cell exposes the sort state through aria-sort.
  5. Key rows with row.id. TanStack Table tracks row identity across sort/filter/pagination; using array indexes will re-mount rows incorrectly.
  6. Always branch on rows.length > 0 and render DataTable.EmptyRow for the empty case. The empty row spans all columns and preserves the table's frame; returning null leaves an empty <tbody> and collapses the frame.
  7. Place an action column last, using columnHelper.display({ ... }). Pair DataTable.ActionHeader (in header) with DataTable.ActionCell (in cell) so the pinned column aligns across header and body when scrolling horizontally.
  8. Pass onClick to DataTable.Row for row-click behavior. The row auto-applies cursor-pointer when onClick is set; do not add it yourself. Override with another cursor-* class (for example, cursor-wait) via className if needed.
  9. Stop click propagation inside DataTable.ActionCell when the row is clickable. Without it, clicks on dropdown triggers, buttons, and links inside the action cell will bubble and fire the row onClick.
  10. 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.
  11. Register features, not row models. useTable wires up only what tableFeatures registers: rowSortingFeature with sortedRowModel for sorting, rowPaginationFeature with paginatedRowModel for pagination, columnFilteringFeature with filteredRowModel for filtering. A feature without its row model is a dead control: rowSortingFeature alone toggles the sort icon and never reorders a row.
  12. Give a compiled component of your own its subscription. mantle ships DataTable compiled 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 stable row, column, cell, or header and reads state through it, wrap that read in TanStack's Subscribe. See TanStack's React Compiler guide.

Anti-patterns

Common mistakes. The left column is what not to do; the right column is the fix.

// ❌ Raw <td> — misses mantle stylingcell: (props) => <td>{props.getValue()}</td>,// ✅cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>, // ❌ Manual cursor-pointer — redundant, can desync from behavior<DataTable.Row className="cursor-pointer" onClick={handle} row={row} />// ✅<DataTable.Row onClick={handle} row={row} /> // ❌ Plain button for a sortable header — no icon, no ARIAheader: () => <button onClick={() => column.toggleSorting()}>Name</button>,// ✅header: (props) => (	<DataTable.Header column={props.column}>		<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">			Name		</DataTable.HeaderSortButton>	</DataTable.Header>), // ❌ Bare link in a clickable row — the link click also runs the row onClick<DataTable.Cell>	<Link to={href("/payments/:id", { id: row.original.id })}>{row.original.id}</Link></DataTable.Cell>// ✅ Sandbox the link's click; the link keeps its own navigation<DataTable.Cell>	<SandboxedOnClick asChild allowClickEventDefault>		<Link to={href("/payments/:id", { id: row.original.id })}>{row.original.id}</Link>	</SandboxedOnClick></DataTable.Cell> // ❌ tabIndex or a widget role on the row — a focusable link inside a role="button" is nested interactive content<DataTable.Row tabIndex={0} role="button" onClick={navigate} row={row} />// ✅ Keep role="row"; the link inside the primary cell is the keyboard path<DataTable.Row onClick={navigate} row={row} /> // ❌ Empty state returns null — collapses the table frame<DataTable.Body>{rows.map((row) => <DataTable.Row key={row.id} row={row} />)}</DataTable.Body>// ✅ Use DataTable.EmptyRow<DataTable.Body>	{rows.length > 0		? rows.map((row) => <DataTable.Row key={row.id} row={row} />)		: <DataTable.EmptyRow>No results.</DataTable.EmptyRow>}</DataTable.Body> // ❌ Registered the sorting feature without its row model: the icon toggles, the rows never reorderconst features = tableFeatures({ rowSortingFeature });// ✅ Register the feature, its row model, and the comparators togetherconst features = tableFeatures({	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); // ❌ Bare array: `useTable` rejects a column typed for `string` where it wants `unknown`const columns = [columnHelper.accessor("email", { id: "email" })];// ✅const columns = columnHelper.columns([columnHelper.accessor("email", { id: "email" })]); // ❌ v8 state getter: `getState()` no longer exists on the tablepageSize={table.getState().pagination.pageSize}// ✅ State is a propertypageSize={table.state.pagination.pageSize}

Recipes

Empty states (no data vs. no results)

A list has two distinct empty states, and they need different copy and actions:

  1. No data: nothing exists yet. Informational, optionally a primary "create" action.
  2. 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:

Actions
m5gr84i9316successken99@example.com
3u1reuv4242successAbe45@example.com
derv1ws0837processingMonserrat44@example.com
5kma53ae874successSilas22@example.com
bhqecj4p721failedcarmella@example.com
import { Button } from "@ngrok/mantle/button";import { DataTable } from "@ngrok/mantle/data-table";import { Empty } from "@ngrok/mantle/empty";import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";import { TrayIcon } from "@phosphor-icons/react/Tray"; const rows = table.getRowModel().rows;const isFiltered = globalFilter.trim() !== ""; // or table.state.columnFilters.length > 0 <DataTable.Body>	{rows.length > 0 ? (		rows.map((row) => <DataTable.Row key={row.id} row={row} />)	) : isFiltered ? (		<DataTable.EmptyRow>			<Empty.Root>				<Empty.Icon svg={<MagnifyingGlassIcon />} />				<Empty.Title>No results match your filter</Empty.Title>				<Empty.Description>					<p>Try a different search, or clear the filter to see everything.</p>				</Empty.Description>				<Empty.Actions>					<Button						type="button"						appearance="outlined"						intent="neutral"						onClick={() => setGlobalFilter("")}					>						Clear filters					</Button>				</Empty.Actions>			</Empty.Root>		</DataTable.EmptyRow>	) : (		<DataTable.EmptyRow>			<Empty.Root>				<Empty.Icon svg={<TrayIcon />} />				<Empty.Title>No endpoints yet</Empty.Title>				<Empty.Description>					<p>Endpoints you create will appear here.</p>				</Empty.Description>				<Empty.Actions>					<Button type="button" appearance="outlined" intent="neutral">						Create endpoint					</Button>				</Empty.Actions>			</Empty.Root>		</DataTable.EmptyRow>	)}</DataTable.Body>;

With no data at all, the table shows the first branch:

Actions

No payments yet

Payments you receive will appear here.

import { Button } from "@ngrok/mantle/button";import { DataTable, useTable } from "@ngrok/mantle/data-table";import { Empty } from "@ngrok/mantle/empty";import { TrayIcon } from "@phosphor-icons/react/Tray"; const noPayments: Payment[] = []; function EmptyPaymentsTable() {	// `features` and `columns` are the ones in the example above.	const table = useTable({ features, data: noPayments, columns }); 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				<DataTable.EmptyRow>					<Empty.Root>						<Empty.Icon svg={<TrayIcon />} />						<Empty.Title>No payments yet</Empty.Title>						<Empty.Description>							<p>Payments you receive will appear here.</p>						</Empty.Description>						<Empty.Actions>							<Button type="button" appearance="filled" intent="neutral">								Create payment							</Button>						</Empty.Actions>					</Empty.Root>				</DataTable.EmptyRow>			</DataTable.Body>		</DataTable.Root>	);}

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.

import {	DataTable,	createColumnHelper,	createSortedRowModel,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { SandboxedOnClick } from "@ngrok/mantle/sandboxed-on-click";import { Link, href, useNavigate } from "react-router"; const features = tableFeatures({	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.accessor("id", {		id: "id",		header: (props) => (			<DataTable.Header column={props.column}>				<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">					ID				</DataTable.HeaderSortButton>			</DataTable.Header>		),		cell: (props) => (			<DataTable.Cell>				<SandboxedOnClick asChild allowClickEventDefault>					<Link to={href("/payments/:id", { id: props.row.original.id })}>{props.getValue()}</Link>				</SandboxedOnClick>			</DataTable.Cell>		),	}),	// ... more columns]); function PaymentsTable({ data }: { data: Payment[] }) {	const navigate = useNavigate();	const table = useTable({ features, data, columns });	const rows = table.getRowModel().rows; 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{rows.map((row) => (					<DataTable.Row						key={row.id}						onClick={() => {							navigate(href("/payments/:id", { id: row.original.id }));						}}						row={row}					/>				))}			</DataTable.Body>		</DataTable.Root>	);}

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.

Actions
ap-southeast-1https://j2c3aqq93ca1p6y1lopper381em.ngrok.stage-ngrok.comEdge2025-01-202025-04-08
ap-southeast-1https://41b5af-a1-dashboard-frontend-controlplane-k1s1.internalEdge2025-03-052025-04-04
eu-west-1https://api-gateway.eu-west.example.internalEdge1st app2025-02-152025-04-09
eu-west-1https://j9be2-dash-controller-80.internalEdge1st app2025-01-152025-04-05
us-east-1https://2c3aq93ca44p6y1pper381em.ngrok.stage-ngrok.comEdge1st app2025-03-012025-04-10
us-east-1https://7jsteel.stage-ngrok.ioEdge2025-02-282025-04-06
us-west-2https://dashboard-frontend-us-west.example.internalEdge1st app2025-03-102025-04-07
us-west-2https://41b5af-a1-dashboard-frontend.internalEdge1st app2025-02-012025-04-03
import { IconButton } from "@ngrok/mantle/button";import { DataTable } from "@ngrok/mantle/data-table";import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";import { Icon } from "@ngrok/mantle/icon";import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree";import { PencilSimpleIcon } from "@phosphor-icons/react/PencilSimple";import { TrashIcon } from "@phosphor-icons/react/Trash"; columnHelper.display({	id: "actions",	header: () => <DataTable.ActionHeader />,	cell: (props) => (		<DataTable.ActionCell>			<DropdownMenu.Root>				<DropdownMenu.Trigger asChild>					<IconButton						appearance="ghost"						intent="neutral"						size="sm"						type="button"						label="Open actions"						icon={<DotsThreeIcon weight="bold" />}					/>				</DropdownMenu.Trigger>				<DropdownMenu.Content align="end">					<DropdownMenu.Item onSelect={() => editRow(props.row.original)}>						<Icon svg={<PencilSimpleIcon />} /> Edit					</DropdownMenu.Item>					<DropdownMenu.Item						className="text-danger-600"						onSelect={() => deleteRow(props.row.original)}					>						<Icon svg={<TrashIcon />} /> Delete					</DropdownMenu.Item>				</DropdownMenu.Content>			</DropdownMenu.Root>		</DataTable.ActionCell>	),});

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.

import { Badge } from "@ngrok/mantle/badge";import { DataTable } from "@ngrok/mantle/data-table"; // Status pillcolumnHelper.accessor("status", {	id: "status",	header: (props) => (		<DataTable.Header column={props.column}>			<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">				Status			</DataTable.HeaderSortButton>		</DataTable.Header>	),	cell: (props) => {		const status = props.getValue();		const color = status === "success" ? "green" : status === "failed" ? "red" : "amber";		return (			<DataTable.Cell>				<Badge color={color} appearance="muted">					{status}				</Badge>			</DataTable.Cell>		);	},}), // Right-aligned numeric — the header button also needs justify-end + iconPlacement="start"// so the sort affordance stays visually paired with the labelcolumnHelper.accessor("amount", {	id: "amount",	header: (props) => (		<DataTable.Header column={props.column}>			<DataTable.HeaderSortButton				className="justify-end"				column={props.column}				iconPlacement="start"				sortingMode="alphanumeric"			>				Amount			</DataTable.HeaderSortButton>		</DataTable.Header>	),	cell: (props) => (		<DataTable.Cell className="text-right">${props.getValue().toFixed(2)}</DataTable.Cell>	),}), // Truncate a long URLcolumnHelper.accessor("url", {	id: "url",	header: (props) => (		<DataTable.Header column={props.column} className="min-w-100">			<DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">				URL			</DataTable.HeaderSortButton>		</DataTable.Header>	),	cell: (props) => <DataTable.Cell className="truncate max-w-100">{props.getValue()}</DataTable.Cell>,}),

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.

TimestampClient IPDuration
Oct 22, 2026 14:03:11.482203.0.113.241.2s
Oct 22, 2026 14:02:57.109198.51.100.748ms
Oct 22, 2026 14:02:40.936192.0.2.1803.9s
import { DataTable, createColumnHelper, tableFeatures, useTable } from "@ngrok/mantle/data-table"; type Connection = {	id: string;	timestamp: string;	clientIp: string;	duration: string;}; const connections: Connection[] = [	{ id: "c1", timestamp: "Oct 22, 2026 14:03:11.482", clientIp: "203.0.113.24", duration: "1.2s" },	{ id: "c2", timestamp: "Oct 22, 2026 14:02:57.109", clientIp: "198.51.100.7", duration: "48ms" },	{ id: "c3", timestamp: "Oct 22, 2026 14:02:40.936", clientIp: "192.0.2.180", duration: "3.9s" },]; const features = tableFeatures({}); const columnHelper = createColumnHelper<typeof features, Connection>(); const columns = columnHelper.columns([	columnHelper.accessor("timestamp", {		id: "timestamp",		meta: { width: "fit" },		header: (props) => <DataTable.Header column={props.column}>Timestamp</DataTable.Header>,		cell: (props) => <DataTable.Cell column={props.column}>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("clientIp", {		id: "clientIp",		header: () => <DataTable.Header>Client IP</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.accessor("duration", {		id: "duration",		header: () => <DataTable.Header>Duration</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),]); function ConnectionsTable() {	const table = useTable({ features, data: connections, columns, getRowId: (row) => row.id }); 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{table.getRowModel().rows.map((row) => (					<DataTable.Row key={row.id} row={row} />				))}			</DataTable.Body>		</DataTable.Root>	);}

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:

import { type DataTableColumnMeta, metaHelper, tableFeatures } from "@ngrok/mantle/data-table"; type ConnectionsColumnMeta = DataTableColumnMeta & { align?: "start" | "end" }; const features = tableFeatures({	columnMeta: metaHelper<ConnectionsColumnMeta>(),});

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.

Actions
pmt_0001100successuser1@example.com
pmt_0002137processinguser2@example.com
pmt_0003174faileduser3@example.com
pmt_0004211pendinguser4@example.com
pmt_0005248successuser5@example.com
pmt_0006285processinguser6@example.com
pmt_0007322faileduser7@example.com
pmt_0008359pendinguser8@example.com
pmt_0009396successuser9@example.com
pmt_0010433processinguser10@example.com
import {	DataTable,	createPaginatedRowModel,	createSortedRowModel,	rowPaginationFeature,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { CursorPagination } from "@ngrok/mantle/pagination"; // The table's initial page size. It must be one of PageSizeSelect's pageSizes// (default 5 | 10 | 20 | 50 | 100).const DEFAULT_PAGE_SIZE = 10; // The headers still sort, so sorting stays registered next to pagination.const features = tableFeatures({	rowPaginationFeature,	rowSortingFeature,	paginatedRowModel: createPaginatedRowModel(),	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); function PaginatedTable({ data }: { data: Payment[] }) {	const table = useTable({		features,		data,		columns,		// `initialState.pagination` takes a whole `PaginationState`, so set both fields.		initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE } },	}); 	const rows = table.getRowModel().rows; 	return (		<>			<DataTable.Root table={table}>				<DataTable.Head />				<DataTable.Body>					{rows.length > 0 ? (						rows.map((row) => <DataTable.Row key={row.id} row={row} />)					) : (						<DataTable.EmptyRow>No results.</DataTable.EmptyRow>					)}				</DataTable.Body>			</DataTable.Root> 			<CursorPagination.Root				className="flex justify-end"				pageSize={table.state.pagination.pageSize}				onChangePageSize={(size) => {					table.setPageSize(size);					table.setPageIndex(0); // reset to the first page when the size changes				}}			>				<CursorPagination.PageSizeSelect />				<CursorPagination.Buttons					hasPreviousPage={table.getCanPreviousPage()}					hasNextPage={table.getCanNextPage()}					onPreviousPage={() => table.previousPage()}					onNextPage={() => table.nextPage()}				/>			</CursorPagination.Root>		</>	);}

Rules:

  • The page size must be one of CursorPagination.PageSizeSelect's pageSizes (default 5 | 10 | 20 | 50 | 100), or the select throws.
  • The table owns the page size: pass the live table.state.pagination.pageSize as pageSize. The select then follows a setPageSize() call from anywhere, including table state you keep in the URL.
  • Reset to page 0 on a size change.
  • Server/cursor data (no rowPaginationFeature): wire Buttons to your fetch's has-next/has-prev + load callbacks, and use the read-only CursorPagination.PageSizeValue when the page size is fixed server-side rather than PageSizeSelect. 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

Actions
m5gr84i9316successken99@example.com
3u1reuv4242successAbe45@example.com
derv1ws0837processingMonserrat44@example.com
5kma53ae874successSilas22@example.com
bhqecj4p721failedcarmella@example.com
import { Checkbox, selectAllChecked } from "@ngrok/mantle/checkbox";import {	DataTable,	type RowSelectionState,	createColumnHelper,	createSortedRowModel,	rowSelectionFeature,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { useState } from "react"; // The payment columns keep their sort buttons, so sorting registers alongside selection.const features = tableFeatures({	rowSelectionFeature,	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.display({		id: "select",		// Match `DataTable.ControlCell`'s width, and center the checkbox (a		// block element) so it lines up with the row checkboxes.		header: ({ table }) => (			<DataTable.Header className="w-9 px-0">				<Checkbox					className="mx-auto"					aria-label="Select all rows"					checked={selectAllChecked({						allSelected: table.getIsAllRowsSelected(),						someSelected: table.getIsSomeRowsSelected(),					})}					onChange={(event) => table.toggleAllRowsSelected(event.target.checked)}				/>			</DataTable.Header>		),		// A click on the cell's padding toggles the checkbox, and no click in the		// cell reaches a clickable row.		cell: ({ row }) => (			<DataTable.ControlCell>				<Checkbox					aria-label={`Select payment ${row.original.id}`}					checked={row.getIsSelected()}					onChange={(event) => row.toggleSelected(event.target.checked)}				/>			</DataTable.ControlCell>		),	}),	// ... data columns]); function SelectableTable({ data }: { data: Payment[] }) {	const [rowSelection, setRowSelection] = useState<RowSelectionState>({});	const table = useTable({		features,		data,		columns,		state: { rowSelection },		onRowSelectionChange: setRowSelection,	}); 	const selectedCount = table.getSelectedRowModel().rows.length; 	return (		<div className="flex flex-col gap-3">			<p className="text-muted text-sm" aria-live="polite">				{selectedCount} of {data.length} selected			</p>			<DataTable.Root table={table}>				<DataTable.Head />				<DataTable.Body>					{table.getRowModel().rows.map((row) => (						<DataTable.Row key={row.id} row={row} />					))}				</DataTable.Body>			</DataTable.Root>		</div>	);}

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

Row detailsActions
m5gr84i9316successken99@example.com
3u1reuv4242successAbe45@example.com
derv1ws0837processingMonserrat44@example.com
5kma53ae874successSilas22@example.com
bhqecj4p721failedcarmella@example.com
import { CodeBlock, jsonCodeBlockValue } from "@ngrok/mantle/code-block";import {	DataTable,	type ExpandedState,	createColumnHelper,	createExpandedRowModel,	createSortedRowModel,	rowExpandingFeature,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { useState } from "react"; // The payment columns keep their sort buttons, so sorting registers alongside// expansion. A stable `getRowId` keeps each open panel with its row when the// order changes.const features = tableFeatures({	rowExpandingFeature,	expandedRowModel: createExpandedRowModel(), // flattens sub-rows into `getRowModel()`; a flat detail panel expands without it	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	// Leading expand-toggle column.	columnHelper.display({		id: "expander",		header: () => <DataTable.ExpandHeader />,		cell: (props) => (			<DataTable.ControlCell>				<DataTable.RowExpandButton row={props.row} label={props.row.original.email} />			</DataTable.ControlCell>		),	}),	// ... your data columns (and an optional sticky action column)]); function ExpandableTable({ data }: { data: Payment[] }) {	// Controlled expansion via native TanStack state. Multiple rows may be open;	// to enforce single-row expansion, reduce `next` down to one key in onExpandedChange.	const [expanded, setExpanded] = useState<ExpandedState>({}); 	const table = useTable({		features,		data,		columns,		state: { expanded },		onExpandedChange: setExpanded,		getRowCanExpand: () => true, // required: no sub-rows exist, so opt every row in		getRowId: (row) => row.id, // stable expansion keys + aria-controls id	}); 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{table.getRowModel().rows.map((row) => (					<DataTable.Row						key={row.id}						row={row}						renderExpanded={(row) => (							<CodeBlock.Root>								<CodeBlock.Body>									<CodeBlock.CopyButton />									<CodeBlock.Code value={jsonCodeBlockValue(row.original)} />								</CodeBlock.Body>							</CodeBlock.Root>						)}					/>				))}			</DataTable.Body>		</DataTable.Root>	);}

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):

import { Fragment } from "react"; {	table.getRowModel().rows.map((row) => (		<Fragment key={row.id}>			<DataTable.Row row={row} />			{row.getIsExpanded() && (				<DataTable.ExpandedRow row={row} colSpan={4}>					{/* any detail markup */}				</DataTable.ExpandedRow>			)}		</Fragment>	));}

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's onClick. 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 onClick to DataTable.Row to open the row's page, as in Navigating on row click. Render a Link in the primary cell, wrapped in SandboxedOnClick, for keyboard and screen-reader users.
  • A leaf row's DataTable.RowExpandButton renders nothing, and its empty DataTable.ControlCell passes 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.

Row detailsDeploymentStatusAge
dep_api_prod_1

api.acme.dev

2 running2d
dep_api_prod_0

api.acme.dev

1 running9d

Click a row to open it.

import { Badge } from "@ngrok/mantle/badge";import {	DataTable,	type ExpandedState,	createColumnHelper,	createExpandedRowModel,	rowExpandingFeature,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table";import { SandboxedOnClick } from "@ngrok/mantle/sandboxed-on-click";import { CaretDownIcon } from "@phosphor-icons/react/CaretDown";import { CaretRightIcon } from "@phosphor-icons/react/CaretRight";import { useState } from "react";import { Link, href, useNavigate } from "react-router"; type Workload = {	id: string;	name: string;	host: string;	status: string;	age: string;	subRows?: Workload[];}; function workloadHref(workload: Workload) {	return href("/workloads/:id", { id: workload.id });} const features = tableFeatures({	rowExpandingFeature,	expandedRowModel: createExpandedRowModel(), // required: puts sub-rows in `getRowModel()`}); const columnHelper = createColumnHelper<typeof features, Workload>(); const columns = columnHelper.columns([	columnHelper.display({		id: "expander",		header: () => <DataTable.ExpandHeader />,		cell: (props) => (			<DataTable.ControlCell>				<DataTable.RowExpandButton					row={props.row}					label={props.row.original.name}					size="xs"					expandIcon={<CaretRightIcon className="size-4" />}					collapseIcon={<CaretDownIcon className="size-4" />}				/>			</DataTable.ControlCell>		),	}),	columnHelper.accessor("name", {		id: "name",		header: () => <DataTable.Header className="px-0">Deployment</DataTable.Header>,		cell: (props) => (			<DataTable.Cell className="pl-0">				<SandboxedOnClick asChild allowClickEventDefault>					<Link						className="font-sans text-strong hover:underline"						to={workloadHref(props.row.original)}					>						{props.getValue()}					</Link>				</SandboxedOnClick>				<p className="text-muted">{props.row.original.host}</p>			</DataTable.Cell>		),	}),	columnHelper.accessor("status", {		id: "status",		header: () => <DataTable.Header>Status</DataTable.Header>,		cell: (props) => (			<DataTable.Cell className="align-top">				<Badge color="green" appearance="muted">					{props.getValue()}				</Badge>			</DataTable.Cell>		),	}),	columnHelper.accessor("age", {		id: "age",		header: () => <DataTable.Header className="text-right">Age</DataTable.Header>,		cell: (props) => (			<DataTable.Cell className="align-top text-right">{props.getValue()}</DataTable.Cell>		),	}),]); function WorkloadsTable({ data }: { data: Workload[] }) {	const navigate = useNavigate();	const [expanded, setExpanded] = useState<ExpandedState>({}); 	const table = useTable({		features,		data,		columns,		state: { expanded },		onExpandedChange: setExpanded,		getSubRows: (row) => row.subRows,		getRowId: (row) => row.id, // stable expansion keys	}); 	return (		<DataTable.Root table={table}>			<DataTable.Head />			<DataTable.Body>				{table.getRowModel().rows.map((row) => (					<DataTable.Row						key={row.id}						row={row}						onClick={() => {							void navigate(workloadHref(row.original));						}}					/>				))}			</DataTable.Body>		</DataTable.Root>	);}

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.

PropTypeDefaultDescription
tableTable<TFeatures, TData>The TanStack Table instance from useTable. Required.
childrenReactNodeTypically DataTable.Head and DataTable.Body.
className?stringExtra classes merged onto the wrapper. Remaining props forward to the wrapper div.

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.

PropTypeDefaultDescription
className?stringExtra classes merged onto the <thead>. Remaining props forward to the HTML <thead> element.

DataTable.Body

The <tbody> container for rows of data. Typically wraps a map of DataTable.Row or a fallback DataTable.EmptyRow.

PropTypeDefaultDescription
childrenReactNodeDataTable.Row elements, or a DataTable.EmptyRow when there is no data.
className?stringExtra classes merged onto the <tbody>. Remaining props forward to the HTML <tbody> element.

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.

PropTypeDefaultDescription
rowRow<TFeatures, TData>The TanStack Table row instance to render. Required.
renderExpanded?(row: Row<TFeatures, TData>) => ReactNodeRenders an inline detail panel below the row, wrapped in a DataTable.ExpandedRow. Called lazily, only while the row is expanded.
onClick?(event: MouseEvent<HTMLTableRowElement>) => voidRuns on a plain primary click. Setting it adds cursor-pointer and data-clickable.
className?stringExtra classes merged onto the <tr>. A cursor-* class here overrides the auto cursor-pointer.

Remaining props forward to the HTML <tr> element.

Data AttributeValueDescription
data-clickablepresent when onClick is setPresence-only. The row runs a handler on a plain click.
data-expandedpresent when expandedPresence-only. The row's detail panel is open.

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.

PropTypeDefaultDescription
childrenReactNodeThe empty-state content (e.g. a centered "No results" message).
className?stringExtra classes merged onto the <tr>. Remaining props forward to the HTML <tr> element.

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.

PropTypeDefaultDescription
childrenReactNodeUsually a DataTable.HeaderSortButton. For non-sortable columns, plain text is fine.
column?Column<TFeatures, TData, TValue>The TanStack Table column this cell heads. When the column is sorted, the cell carries aria-sort. When the column's meta.width is "fit", the column sizes to its content.
className?stringExtra classes merged onto the <th>. Remaining props forward to the HTML <th> element.

Data attributes

Data AttributeValueDescription
data-slot"data-table-header table-header"On the <th>, joined ahead of Table.Header's own slot.
data-column-width"fit"Present when the column prop's meta.width is "fit". The header does not wrap its content, and the column shrinks to fit it.

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.

PropTypeDefaultDescription
columnColumn<TFeatures, TData, TValue>The TanStack Table column instance (from props.column in header). TFeatures must include rowSortingFeature. Required.
sortingMode"alphanumeric" | "time"Which direction sequence to cycle through. Required unless disableSorting is true.
disableSorting?booleanfalseRenders the label as plain text with no button and no sort icon. Makes sortingMode unnecessary.
sortIcon?(sortDirection: SortDirection) => ReactNodeOverride the default sort icon. sortDirection is "asc" | "desc" | "unsorted".
iconPlacement?"start" | "end""end"The side the sort icon renders on. Use "start" for right-aligned numeric columns.
appearance?"ghost" | "filled" | "outlined" | "link""ghost"The visual style of the underlying Button. Optional here — the sort button's design is a ghost button, so the wrapper defaults it.
intent?"accent" | "danger" | "neutral""neutral"The tone of the underlying Button. Optional here — the sort button's design is neutral-toned, so the wrapper defaults it.
onClick?(event: MouseEvent<HTMLButtonElement>) => voidCalled before the sort toggles. Call event.preventDefault() to skip the toggle.
className?stringExtra classes merged onto the underlying Button — for example, justify-end for right-aligned numeric columns.

All additional props from Button. Unlike Button itself, appearance and intent are optional here — the wrapper defaults them to "ghost" and "neutral".

Data AttributeValueDescription
data-sort-direction"asc" | "desc" | "unsorted"The column's current sort direction. Always "unsorted" on the plain-text span.
data-table-header-actionpresent on the buttonPresence-only. DataTable.Header drops its horizontal padding when a descendant carries it. Absent on the plain-text span.

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.

PropTypeDefaultDescription
childrenReactNodeThe cell's rendered value. Any React node is allowed.
column?Column<TFeatures, TData, TValue>The TanStack Table column this cell belongs to. When the column's meta.width is "fit", the column sizes to its content.
className?stringExtra classes merged onto the <td>. Remaining props forward to the HTML <td> element.

Data attributes

Data AttributeValueDescription
data-slot"data-table-cell table-cell"On the <td>, joined ahead of Table.Cell's own slot.
data-column-width"fit"Present when the column prop's meta.width is "fit". The cell does not wrap its content, and the column shrinks to fit it.

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.

PropTypeDefaultDescription
children?ReactNodeHeader content. When omitted, a screen-reader-only "Actions" label.
className?stringExtra classes merged onto the <th>. Remaining props forward to the HTML <th> element.

Data attributes

Data AttributeValueDescription
data-slot"data-table-action-header table-header"On the <th>, joined ahead of Table.Header's own slot.
data-slot"data-table-action-header-label"On the <span> wrapping children.

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.

PropTypeDefaultDescription
childrenReactNodeThe row's action controls.
onClick?(event: MouseEvent<HTMLTableCellElement>) => voidRuns after the cell stops propagation; the click never reaches the row.
className?stringExtra classes merged onto the <td>. Remaining props forward to the HTML <td> element.

Data attributes

Data AttributeValueDescription
data-slot"data-table-action-cell table-cell"On the <td>, joined ahead of Table.Cell's own slot.
data-slot"data-table-action-cell-label"On the <span> wrapping children.

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

PropTypeDefaultDescription
childrenReactNodeThe row's control: a DataTable.RowExpandButton or a Checkbox.
onClick?(event: MouseEvent<HTMLTableCellElement>) => voidRuns first. Call event.preventDefault() to stop a padding click from reaching the control.
className?stringExtra classes merged onto the <td>. Pass a cursor-* class to override the cursor. Remaining props forward to the HTML <td>.

Data attributes

Data AttributeValueDescription
data-slot"data-table-control-cell table-cell"On the <td>, joined ahead of Table.Cell's own slot.
data-slot"data-table-control-cell-label"On the <span> wrapping children.

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.

PropTypeDefaultDescription
children?ReactNodeOptional header content, e.g. an "expand all" toggle wired to table.getToggleAllRowsExpandedHandler().
className?stringExtra classes merged onto the <th>. Remaining props forward to the HTML <th> element.

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.

PropTypeDefaultDescription
rowRow<TFeatures, TData>The TanStack Table row this button toggles. TFeatures must include rowExpandingFeature. Required.
labelstringA human-readable row name, woven into the accessible label: Show details for … / Hide details for …, or Expand … / Collapse … for a row with sub-rows. Required.
expandIcon?ReactNode<PlusIcon weight="bold"/>Icon shown while the row is collapsed.
collapseIcon?ReactNode<MinusIcon weight="bold"/>Icon shown while the row is expanded.
appearance?"filled" | "ghost" | "outlined""ghost"The visual style of the underlying IconButton. Optional here — the expand toggle's design is a ghost button, so the wrapper defaults it.
intent?"neutral""neutral"The tone of the underlying IconButton. Optional here — the expand toggle's design is neutral-toned, and it is the only tone IconButton draws.
onClick?(event) => voidRuns before the toggle; call event.preventDefault() to veto expansion. Other IconButton props forward too.

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.

PropTypeDefaultDescription
rowRow<TFeatures, TData>The row whose detail panel this displays. Required.
childrenReactNodeThe detail content rendered inside the full-width panel. Required.
colSpan?numberthe visible cell countOverride the cell's colSpan. Defaults to the row's visible cells, or every cell without columnVisibilityFeature.
className?stringExtra classes merged onto the <tr>. Remaining props forward to the HTML <tr> element.