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 { href, useNavigate } from "react-router";11import { useMemo } from "react";12 13type Payment = {14 id: string;15 amount: number;16 status: "pending" | "processing" | "success" | "failed";17 email: string;18};19 20const columnHelper = createColumnHelper<Payment>();21 22const columns = [23 columnHelper.accessor("id", {24 id: "id",25 header: (props) => (26 <DataTable.Header>27 <DataTable.HeaderSortButton column={props.column} sortingMode="alphanumeric">28 ID29 </DataTable.HeaderSortButton>30 </DataTable.Header>31 ),32 cell: (props) => <DataTable.Cell key={props.cell.id}>{props.getValue()}</DataTable.Cell>,33 }),34 // ... more columns35];36 37function PaymentsExample() {38 const navigate = useNavigate();39 const data = useMemo(() => examplePayments, []);40 41 const table = useReactTable({42 data,43 columns,44 getCoreRowModel: getCoreRowModel(),45 getPaginationRowModel: getPaginationRowModel(),46 getSortedRowModel: getSortedRowModel(),47 getFilteredRowModel: getFilteredRowModel(),48 initialState: {49 sorting: [{ id: "email", desc: false }],50 pagination: { pageSize: 100 },51 },52 });53 54 const rows = table.getRowModel().rows;55 56 return (57 <DataTable.Root table={table}>58 <DataTable.Head />59 <DataTable.Body>60 {rows.length > 0 ? (61 rows.map((row) => (62 <DataTable.Row63 key={row.id}64 onClick={() => {65 navigate(href("/payments/:id", { id: row.original.id }));66 }}67 row={row}68 />69 ))70 ) : (71 <DataTable.EmptyRow>72 <p className="flex items-center justify-center min-h-20">No results.</p>73 </DataTable.EmptyRow>74 )}75 </DataTable.Body>76 </DataTable.Root>77 );78}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});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() and drive page controls from the table instance (table.getState().pagination, table.previousPage(), table.nextPage(), table.setPageIndex(), table.getPageCount()).
1import { Button } from "@ngrok/mantle/button";2import {3 DataTable,4 getCoreRowModel,5 getPaginationRowModel,6 useReactTable,7} from "@ngrok/mantle/data-table";8 9function PaginatedTable({ data }: { data: Payment[] }) {10 const table = useReactTable({11 data,12 columns,13 getCoreRowModel: getCoreRowModel(),14 getPaginationRowModel: getPaginationRowModel(),15 initialState: { pagination: { pageSize: 25 } },16 });17 18 const rows = table.getRowModel().rows;19 const { pageIndex } = table.getState().pagination;20 21 return (22 <>23 <DataTable.Root table={table}>24 <DataTable.Head />25 <DataTable.Body>26 {rows.length > 0 ? (27 rows.map((row) => <DataTable.Row key={row.id} row={row} />)28 ) : (29 <DataTable.EmptyRow>No results.</DataTable.EmptyRow>30 )}31 </DataTable.Body>32 </DataTable.Root>33 34 <div className="flex items-center justify-between gap-2">35 <span>36 Page {pageIndex + 1} of {table.getPageCount()}37 </span>38 <div className="flex gap-2">39 <Button40 type="button"41 onClick={() => table.previousPage()}42 disabled={!table.getCanPreviousPage()}43 >44 Previous45 </Button>46 <Button type="button" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>47 Next48 </Button>49 </div>50 </div>51 </>52 );53}Use columnHelper.display for the checkbox column and read selection from table.getState().rowSelection.
1import { Checkbox } from "@ngrok/mantle/checkbox";2import { DataTable, getCoreRowModel, useReactTable } from "@ngrok/mantle/data-table";3import { useState } from "react";4 5const columns = [6 columnHelper.display({7 id: "select",8 header: ({ table }) => (9 <DataTable.Header className="w-10">10 <Checkbox11 checked={12 table.getIsAllRowsSelected()13 ? true14 : table.getIsSomeRowsSelected()15 ? "indeterminate"16 : false17 }18 onCheckedChange={(value) => table.toggleAllRowsSelected(value === true)}19 aria-label="Select all rows"20 />21 </DataTable.Header>22 ),23 cell: ({ row }) => (24 <DataTable.Cell className="w-10">25 <Checkbox26 checked={row.getIsSelected()}27 onCheckedChange={(value) => row.toggleSelected(value === true)}28 aria-label="Select row"29 />30 </DataTable.Cell>31 ),32 }),33 // ... data columns34];35 36function SelectableTable({ data }: { data: Payment[] }) {37 const [rowSelection, setRowSelection] = useState({});38 const table = useReactTable({39 data,40 columns,41 getCoreRowModel: getCoreRowModel(),42 state: { rowSelection },43 onRowSelectionChange: setRowSelection,44 enableRowSelection: true,45 });46 47 const selectedRows = table.getSelectedRowModel().rows;48 49 return (50 <DataTable.Root table={table}>51 <DataTable.Head />52 <DataTable.Body>53 {table.getRowModel().rows.map((row) => (54 <DataTable.Row key={row.id} row={row} />55 ))}56 </DataTable.Body>57 </DataTable.Root>58 );59}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.