Tables purposefully designed for dynamic, application data with features like sorting, filtering, and pagination. Powered by TanStack Table.
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.
Table for static, presentational tabular content (pricing matrices, reference tables, invoice summaries).createColumnHelper, getCoreRowModel, getSortedRowModel, getPaginationRowModel, getFilteredRowModel, useReactTable, etc.) are re-exported from @ngrok/mantle/data-table — you do not need to add @tanstack/react-table as a separate dependency.The minimum viable DataTable. Copy, paste, replace the type and data:
1import {2 DataTable,3 createColumnHelper,4 getCoreRowModel,5 useReactTable,6} from "@ngrok/mantle/data-table";7 8type Row = { id: string; name: string };9 10const columnHelper = createColumnHelper<Row>();11 12const columns = [13 columnHelper.accessor("name", {14 id: "name",15 header: (props) => (16 <DataTable.Header>17 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">18 Name19 </DataTable.HeaderSortButton>20 </DataTable.Header>21 ),22 cell: (props) => <DataTable.Cell key={props.cell.id}>{props.getValue()}</DataTable.Cell>,23 }),24];25 26function MyTable({ data }: { data: Row[] }) {27 const table = useReactTable({28 data,29 columns,30 getCoreRowModel: getCoreRowModel(),31 });32 33 const rows = table.getRowModel().rows;34 35 return (36 <DataTable.Root table={table}>37 <DataTable.Head />38 <DataTable.Body>39 {rows.length > 0 ? (40 rows.map((row) => <DataTable.Row key={row.id} row={row} />)41 ) : (42 <DataTable.EmptyRow>No results.</DataTable.EmptyRow>43 )}44 </DataTable.Body>45 </DataTable.Root>46 );47}A fuller example matching the demo above — sortable columns, pagination, filtering, row-click navigation, and a sticky action column:
1import {2 DataTable,3 createColumnHelper,4 getCoreRowModel,5 getFilteredRowModel,6 getPaginationRowModel,7 getSortedRowModel,8 useReactTable,9} from "@ngrok/mantle/data-table";10import { Empty } from "@ngrok/mantle/empty";11import { TrayIcon } from "@phosphor-icons/react/Tray";12import { href, useNavigate } from "react-router";13import { useMemo } from "react";14 15type Payment = {16 id: string;17 amount: number;18 status: "pending" | "processing" | "success" | "failed";19 email: string;20};21 22const columnHelper = createColumnHelper<Payment>();23 24const columns = [25 columnHelper.accessor("id", {26 id: "id",27 header: (props) => (28 <DataTable.Header>29 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">30 ID31 </DataTable.HeaderSortButton>32 </DataTable.Header>33 ),34 cell: (props) => <DataTable.Cell key={props.cell.id}>{props.getValue()}</DataTable.Cell>,35 }),36 // ... more columns37];38 39function PaymentsExample() {40 const navigate = useNavigate();41 const data = useMemo(() => examplePayments, []);42 43 const table = useReactTable({44 data,45 columns,46 getCoreRowModel: getCoreRowModel(),47 getPaginationRowModel: getPaginationRowModel(),48 getSortedRowModel: getSortedRowModel(),49 getFilteredRowModel: getFilteredRowModel(),50 initialState: {51 sorting: [{ id: "email", desc: false }],52 pagination: { pageSize: 100 },53 },54 });55 56 const rows = table.getRowModel().rows;57 58 return (59 <DataTable.Root table={table}>60 <DataTable.Head />61 <DataTable.Body>62 {rows.length > 0 ? (63 rows.map((row) => (64 <DataTable.Row65 key={row.id}66 onClick={() => {67 navigate(href("/payments/:id", { id: row.original.id }));68 }}69 row={row}70 />71 ))72 ) : (73 <DataTable.EmptyRow>74 <Empty.Root>75 <Empty.Icon svg={<TrayIcon />} />76 <Empty.Title>No payments yet</Empty.Title>77 <Empty.Description>78 <p>Payments you receive will appear here.</p>79 </Empty.Description>80 </Empty.Root>81 </DataTable.EmptyRow>82 )}83 </DataTable.Body>84 </DataTable.Root>85 );86}Compose the parts of a DataTable together to build your own:
DataTable.Root├── DataTable.Head│ └── DataTable.Row│ ├── DataTable.Header│ │ └── DataTable.HeaderSortButton│ └── DataTable.ActionHeader└── DataTable.Body ├── DataTable.Row │ ├── DataTable.Cell │ └── DataTable.ActionCell └── DataTable.EmptyRowFollow these invariants for a correctly styled, accessible, and behaving DataTable.
createColumnHelper<TData>(). It threads TData through header, cell, and row.original so consumers get inference instead of unknown.DataTable.Cell. A raw <td> skips the mantle typography, padding, and sticky-column styling.DataTable.Header. For sortable columns, also wrap its contents in DataTable.HeaderSortButton — the icon, cycling behavior, and screen-reader announcements are provided by that button.row.id. TanStack Table tracks row identity across sort/filter/pagination; using array indexes will re-mount rows incorrectly.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.columnHelper.display({ ... }). Pair DataTable.ActionHeader (in header) with DataTable.ActionCell (in cell) so the pinned column aligns across header and body when scrolling horizontally.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.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.<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.useReactTable only wires up what you pass in: getSortedRowModel() for sorting, getPaginationRowModel() for pagination, getFilteredRowModel() for filtering. Missing one and the corresponding feature silently no-ops.Common mistakes. The left column is what not to do; the right column is the fix.
1// ❌ Raw <td> — misses mantle styling2cell: (props) => <td>{props.getValue()}</td>,3// ✅4cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,5 6// ❌ Manual cursor-pointer — redundant, can desync from behavior7<DataTable.Row className="cursor-pointer" onClick={handle} row={row} />8// ✅9<DataTable.Row onClick={handle} row={row} />10 11// ❌ Plain button for a sortable header — no icon, no ARIA12header: () => <button onClick={() => column.toggleSorting()}>Name</button>,13// ✅14header: (props) => (15 <DataTable.Header>16 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">17 Name18 </DataTable.HeaderSortButton>19 </DataTable.Header>20),21 22// ❌ Clickable row with a dropdown inside — trigger click fires the row onClick23<DataTable.Row onClick={navigate} row={row}>24 <DataTable.ActionCell>25 <DropdownMenu.Root>...</DropdownMenu.Root>26 </DataTable.ActionCell>27</DataTable.Row>28// ✅ Stop propagation at the action cell boundary29<DataTable.Row onClick={navigate} row={row}>30 <DataTable.ActionCell onClick={(event) => event.stopPropagation()}>31 <DropdownMenu.Root>...</DropdownMenu.Root>32 </DataTable.ActionCell>33</DataTable.Row>34 35// ❌ Empty state returns null — collapses the table frame36<DataTable.Body>{rows.map((row) => <DataTable.Row key={row.id} row={row} />)}</DataTable.Body>37// ✅ Use DataTable.EmptyRow38<DataTable.Body>39 {rows.length > 040 ? rows.map((row) => <DataTable.Row key={row.id} row={row} />)41 : <DataTable.EmptyRow>No results.</DataTable.EmptyRow>}42</DataTable.Body>43 44// ❌ Declared sorting but forgot the row model45useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });46// ✅47useReactTable({48 data,49 columns,50 getCoreRowModel: getCoreRowModel(),51 getSortedRowModel: getSortedRowModel(),52});A list has two distinct empty states, and they need different copy and actions:
Branch on rows.length, then on whether a filter is active, and host an Empty inside DataTable.EmptyRow for each branch. 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:
1import { Button } from "@ngrok/mantle/button";2import { DataTable } from "@ngrok/mantle/data-table";3import { Empty } from "@ngrok/mantle/empty";4import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";5import { TrayIcon } from "@phosphor-icons/react/Tray";6 7const rows = table.getRowModel().rows;8const isFiltered = globalFilter.trim() !== ""; // or table.getState().columnFilters.length > 09 10<DataTable.Body>11 {rows.length > 0 ? (12 rows.map((row) => <DataTable.Row key={row.id} row={row} />)13 ) : isFiltered ? (14 <DataTable.EmptyRow>15 <Empty.Root>16 <Empty.Icon svg={<MagnifyingGlassIcon />} />17 <Empty.Title>No results match your filter</Empty.Title>18 <Empty.Description>19 <p>Try a different search, or clear the filter to see everything.</p>20 </Empty.Description>21 <Empty.Actions>22 <Button23 type="button"24 appearance="outlined"25 priority="neutral"26 onClick={() => setGlobalFilter("")}27 >28 Clear filters29 </Button>30 </Empty.Actions>31 </Empty.Root>32 </DataTable.EmptyRow>33 ) : (34 <DataTable.EmptyRow>35 <Empty.Root>36 <Empty.Icon svg={<TrayIcon />} />37 <Empty.Title>No endpoints yet</Empty.Title>38 <Empty.Description>39 <p>Endpoints you create will appear here.</p>40 </Empty.Description>41 <Empty.Actions>42 <Button type="button">Create endpoint</Button>43 </Empty.Actions>44 </Empty.Root>45 </DataTable.EmptyRow>46 )}47</DataTable.Body>;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 acts as a larger pointer target on top.
1import { DataTable } from "@ngrok/mantle/data-table";2import { Link, href, useNavigate } from "react-router";3 4const columns = [5 columnHelper.accessor("id", {6 id: "id",7 header: (props) => (8 <DataTable.Header>9 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">10 ID11 </DataTable.HeaderSortButton>12 </DataTable.Header>13 ),14 cell: (props) => (15 <DataTable.Cell>16 <Link to={href("/payments/:id", { id: props.row.original.id })}>{props.getValue()}</Link>17 </DataTable.Cell>18 ),19 }),20 // ... more columns21];22 23function PaymentsTable({ data }: { data: Payment[] }) {24 const navigate = useNavigate();25 const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });26 const rows = table.getRowModel().rows;27 28 return (29 <DataTable.Root table={table}>30 <DataTable.Head />31 <DataTable.Body>32 {rows.map((row) => (33 <DataTable.Row34 key={row.id}35 onClick={() => {36 navigate(href("/payments/:id", { id: row.original.id }));37 }}38 row={row}39 />40 ))}41 </DataTable.Body>42 </DataTable.Root>43 );44}Define the action column with columnHelper.display, pair DataTable.ActionHeader with DataTable.ActionCell, and stop click propagation on the cell if the row is also clickable.
1import { IconButton } from "@ngrok/mantle/button";2import { DataTable } from "@ngrok/mantle/data-table";3import { DropdownMenu } from "@ngrok/mantle/dropdown-menu";4import { Icon } from "@ngrok/mantle/icon";5import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree";6import { PencilSimpleIcon } from "@phosphor-icons/react/PencilSimple";7import { TrashIcon } from "@phosphor-icons/react/Trash";8 9columnHelper.display({10 id: "actions",11 header: () => <DataTable.ActionHeader />,12 cell: (props) => (13 <DataTable.ActionCell onClick={(event) => event.stopPropagation()}>14 <DropdownMenu.Root>15 <DropdownMenu.Trigger asChild>16 <IconButton17 appearance="ghost"18 size="sm"19 type="button"20 label="Open actions"21 icon={<DotsThreeIcon weight="bold" />}22 />23 </DropdownMenu.Trigger>24 <DropdownMenu.Content align="end">25 <DropdownMenu.Item onSelect={() => editRow(props.row.original)}>26 <Icon svg={<PencilSimpleIcon />} /> Edit27 </DropdownMenu.Item>28 <DropdownMenu.Item29 className="text-danger-600"30 onSelect={() => deleteRow(props.row.original)}31 >32 <Icon svg={<TrashIcon />} /> Delete33 </DropdownMenu.Item>34 </DropdownMenu.Content>35 </DropdownMenu.Root>36 </DataTable.ActionCell>37 ),38});Cell rendering is just React. Use Badge for status pills, text-right for numeric columns, and truncate max-w-* for long strings.
1import { Badge } from "@ngrok/mantle/badge";2import { DataTable } from "@ngrok/mantle/data-table";3 4// Status pill5columnHelper.accessor("status", {6 id: "status",7 header: (props) => (8 <DataTable.Header>9 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">10 Status11 </DataTable.HeaderSortButton>12 </DataTable.Header>13 ),14 cell: (props) => {15 const status = props.getValue();16 const color = status === "success" ? "green" : status === "failed" ? "red" : "amber";17 return (18 <DataTable.Cell>19 <Badge color={color} appearance="muted">20 {status}21 </Badge>22 </DataTable.Cell>23 );24 },25}),26 27// Right-aligned numeric — the header button also needs justify-end + iconPlacement="start"28// so the sort affordance stays visually paired with the label29columnHelper.accessor("amount", {30 id: "amount",31 header: (props) => (32 <DataTable.Header>33 <DataTable.HeaderSortButton34 className="justify-end"35 column={props.column}36 iconPlacement="start"37 sortingMode="alphanumeric"38 >39 Amount40 </DataTable.HeaderSortButton>41 </DataTable.Header>42 ),43 cell: (props) => (44 <DataTable.Cell className="text-right">${props.getValue().toFixed(2)}</DataTable.Cell>45 ),46}),47 48// Truncate a long URL49columnHelper.accessor("url", {50 id: "url",51 header: (props) => (52 <DataTable.Header className="min-w-100">53 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">54 URL55 </DataTable.HeaderSortButton>56 </DataTable.Header>57 ),58 cell: (props) => <DataTable.Cell className="truncate max-w-100">{props.getValue()}</DataTable.Cell>,59}),Register getPaginationRowModel(), 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.
1import {2 DataTable,3 getCoreRowModel,4 getPaginationRowModel,5 useReactTable,6} from "@ngrok/mantle/data-table";7import { CursorPagination } from "@ngrok/mantle/pagination";8 9// defaultPageSize seeds an UNCONTROLLED <Select>, so keep it stable — a module10// const (or the table's INITIAL page size), never the live page size. It must be11// one of CursorPagination's pageSizes (default 5 | 10 | 20 | 50 | 100).12const DEFAULT_PAGE_SIZE = 10;13 14function PaginatedTable({ data }: { data: Payment[] }) {15 const table = useReactTable({16 data,17 columns,18 getCoreRowModel: getCoreRowModel(),19 getPaginationRowModel: getPaginationRowModel(),20 initialState: { pagination: { pageSize: DEFAULT_PAGE_SIZE } },21 });22 23 const rows = table.getRowModel().rows;24 25 return (26 <>27 <DataTable.Root table={table}>28 <DataTable.Head />29 <DataTable.Body>30 {rows.length > 0 ? (31 rows.map((row) => <DataTable.Row key={row.id} row={row} />)32 ) : (33 <DataTable.EmptyRow>No results.</DataTable.EmptyRow>34 )}35 </DataTable.Body>36 </DataTable.Root>37 38 <CursorPagination.Root className="flex justify-end" defaultPageSize={DEFAULT_PAGE_SIZE}>39 <CursorPagination.PageSizeSelect40 onChangePageSize={(size) => {41 table.setPageSize(size);42 table.setPageIndex(0); // reset to the first page when the size changes43 }}44 />45 <CursorPagination.Buttons46 hasPreviousPage={table.getCanPreviousPage()}47 hasNextPage={table.getCanNextPage()}48 onPreviousPage={() => table.previousPage()}49 onNextPage={() => table.nextPage()}50 />51 </CursorPagination.Root>52 </>53 );54}Rules:
defaultPageSize must be present in CursorPagination.PageSizeSelect's pageSizes (default 5 | 10 | 20 | 50 | 100) or the select renders blank.defaultPageSize is an uncontrolled seed — derive it from the table's initial page size once; don't pass the live table.getState().pagination.pageSize (it changes and would thrash the select).getPaginationRowModel): 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.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. Drive rowSelection through useReactTable 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.
0 of 5 selected
1import { Checkbox, selectAllChecked } from "@ngrok/mantle/checkbox";2import {3 DataTable,4 getCoreRowModel,5 type RowSelectionState,6 useReactTable,7} from "@ngrok/mantle/data-table";8import { useState } from "react";9 10const columns = [11 columnHelper.display({12 id: "select",13 // `<th>` defaults to more horizontal padding (`px-4`) than `<td>` (`p-3`);14 // match the cell's padding so the header checkbox lines up with the rows.15 header: ({ table }) => (16 <DataTable.Header className="w-10 px-3">17 <Checkbox18 aria-label="Select all rows"19 checked={selectAllChecked({20 allSelected: table.getIsAllRowsSelected(),21 someSelected: table.getIsSomeRowsSelected(),22 })}23 onChange={(event) => table.toggleAllRowsSelected(event.target.checked)}24 />25 </DataTable.Header>26 ),27 cell: ({ row }) => (28 <DataTable.Cell className="w-10">29 <Checkbox30 aria-label="Select row"31 checked={row.getIsSelected()}32 onChange={(event) => row.toggleSelected(event.target.checked)}33 />34 </DataTable.Cell>35 ),36 }),37 // ... data columns38];39 40function SelectableTable({ data }: { data: Payment[] }) {41 const [rowSelection, setRowSelection] = useState<RowSelectionState>({});42 const table = useReactTable({43 data,44 columns,45 getCoreRowModel: getCoreRowModel(),46 state: { rowSelection },47 onRowSelectionChange: setRowSelection,48 enableRowSelection: true,49 });50 51 const selectedCount = table.getSelectedRowModel().rows.length;52 53 return (54 <div className="flex flex-col gap-3">55 <p className="text-muted text-sm">56 {selectedCount} of {data.length} selected57 </p>58 <DataTable.Root table={table}>59 <DataTable.Head />60 <DataTable.Body>61 {table.getRowModel().rows.map((row) => (62 <DataTable.Row key={row.id} row={row} />63 ))}64 </DataTable.Body>65 </DataTable.Root>66 </div>67 );68}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), so wire it up on useReactTable with getExpandedRowModel() and getRowCanExpand: () => true (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.
Two parts cover the toggle: DataTable.ExpandHeader (the toggle column header) and DataTable.RowExpandButton (the accessible +/− toggle, dropped into a leading columnHelper.display cell). 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.
The detail panel below renders each row's object as JSON via CodeBlock, highlighted with jsonCodeBlockValue — 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).
1import { CodeBlock, jsonCodeBlockValue } from "@ngrok/mantle/code-block";2import {3 DataTable,4 type ExpandedState,5 createColumnHelper,6 getCoreRowModel,7 getExpandedRowModel,8 useReactTable,9} from "@ngrok/mantle/data-table";10import { useState } from "react";11 12const columnHelper = createColumnHelper<Payment>();13 14const columns = [15 // Leading expand-toggle column.16 columnHelper.display({17 id: "expander",18 header: () => <DataTable.ExpandHeader />,19 cell: (props) => (20 <DataTable.Cell className="w-9 px-0 text-center">21 <DataTable.RowExpandButton row={props.row} label={props.row.original.email} />22 </DataTable.Cell>23 ),24 }),25 // ... your data columns (and an optional sticky action column)26];27 28function ExpandableTable({ data }: { data: Payment[] }) {29 // Controlled expansion via native TanStack state. Multiple rows may be open;30 // to enforce single-row expansion, reduce `next` down to one key in onExpandedChange.31 const [expanded, setExpanded] = useState<ExpandedState>({});32 33 const table = useReactTable({34 data,35 columns,36 state: { expanded },37 onExpandedChange: setExpanded,38 getRowCanExpand: () => true, // required: no sub-rows exist, so opt every row in39 getCoreRowModel: getCoreRowModel(),40 getExpandedRowModel: getExpandedRowModel(), // required, or nothing expands41 getRowId: (row) => row.id, // stable expansion keys + aria-controls id42 });43 44 return (45 <DataTable.Root table={table}>46 <DataTable.Head />47 <DataTable.Body>48 {table.getRowModel().rows.map((row) => (49 <DataTable.Row50 key={row.id}51 row={row}52 renderExpanded={(row) => (53 <CodeBlock.Root>54 <CodeBlock.Body>55 <CodeBlock.CopyButton />56 <CodeBlock.Code value={jsonCodeBlockValue(row.original)} />57 </CodeBlock.Body>58 </CodeBlock.Root>59 )}60 />61 ))}62 </DataTable.Body>63 </DataTable.Root>64 );65}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):
1import { Fragment } from "react";2 3{4 table.getRowModel().rows.map((row) => (5 <Fragment key={row.id}>6 <DataTable.Row row={row} />7 {row.getIsExpanded() && (8 <DataTable.ExpandedRow row={row} colSpan={4}>9 {/* any detail markup */}10 </DataTable.ExpandedRow>11 )}12 </Fragment>13 ));14}The DataTable components are built on top of TanStack Table. All TanStack Table utilities (createColumnHelper, getCoreRowModel, getSortedRowModel, getPaginationRowModel, getFilteredRowModel, useReactTable, etc.) are re-exported from @ngrok/mantle/data-table.
The root container for the data table. Wraps all other DataTable sub-components and provides the table context. Delegates rendering to Table.Root.
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.
The <tbody> container for rows of data. Typically wraps a map of DataTable.Row or a fallback DataTable.EmptyRow.
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 provided, the row automatically receives cursor-pointer. Pass a different cursor-* class via className (for example, cursor-wait) to override.
Remaining props forward to the HTML <tr> element.
An empty-state row that spans every column. Render this as the else branch when rows.length === 0 to keep the table's frame intact.
A <th> cell optimized for header actions. Wrap sortable headers' contents in 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.
All additional props from Button.
A <td> for rendering individual data cells. Provides mantle typography, padding, and alignment. Re-exported from Table.Cell.
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.
A sticky-right <td> for per-row action buttons (typically an IconButton that opens a DropdownMenu).
When the row has an onClick, pass onClick={(event) => event.stopPropagation()} on the action cell so clicks on controls inside do not bubble and trigger the row handler.
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.
An accessible +/− toggle that expands or collapses a row's detail panel. Place it inside a DataTable.Cell in a leading columnHelper.display column and pair it with DataTable.ExpandedRow. Renders a real <button> that sets aria-expanded and (while expanded) aria-controls, stops click propagation so it never fires a row-level onClick, and renders nothing when row.getCanExpand() is false. Forwards IconButton props.
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.