Components
Powerful table and datagrids built using TanStack Table. A pattern — not a @workspace/ui component — composing Table with headless state from @tanstack/react-table.
Playground
| Status | Amount | |||
|---|---|---|---|---|
success | ken99@example.com | $316.00 | ||
success | Abe45@example.com | $242.00 | ||
processing | Monserrat44@example.com | $837.00 |
Table parts and TanStack Table v9. Add the dependency to the consuming app:yarn add @tanstack/react-table
tableFeatures() — anything you don't register (including built-in filter and sort functions) is tree-shaken out of the bundle. Share the object across your column and table modules:// data-table-features.ts
import {
columnFilteringFeature,
columnVisibilityFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_text,
tableFeatures,
} from "@tanstack/react-table"
export const features = tableFeatures({
columnFilteringFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns: { includesString: filterFn_includesString },
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})
export type DataTableFeatures = typeof featurescreateColumnHelper — typed by your features object and row shape — create the instance with useTable, and render it by looping header groups and rows into the Table parts. table.FlexRender renders each header and cell definition in place.| Status | Amount | |
|---|---|---|
| success | ken99@example.com | 316 |
| success | Abe45@example.com | 242 |
| processing | Monserrat44@example.com | 837 |
| success | Silas22@example.com | 874 |
| failed | carmella@example.com | 721 |
"use client"
import { createColumnHelper, useTable } from "@tanstack/react-table"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table"
import { features, type DataTableFeatures } from "./data-table-features"
type Payment = {
id: string
amount: number
status: "pending" | "processing" | "success" | "failed"
email: string
}
const columnHelper = createColumnHelper<DataTableFeatures, Payment>()
const columns = columnHelper.columns([
columnHelper.accessor("status", { header: "Status" }),
columnHelper.accessor("email", { header: "Email" }),
columnHelper.accessor("amount", { header: "Amount" }),
])
export function DataTable({ data }: { data: Payment[] }) {
const table = useTable({ features, data, columns })
return (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<table.FlexRender header={header} />
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}cell function formats what renders — here the amount becomes a currency string, end-aligned along with its header via text-end. Use row.getValue() for the column value or row.original for the full row.| Status | Amount | |
|---|---|---|
success | ken99@example.com | $316.00 |
success | Abe45@example.com | $242.00 |
processing | Monserrat44@example.com | $837.00 |
success | Silas22@example.com | $874.00 |
failed | carmella@example.com | $721.00 |
const columns = columnHelper.columns([
columnHelper.accessor("status", {
header: "Status",
cell: ({ row }) => (
<div className="capitalize">{row.getValue("status")}</div>
),
}),
columnHelper.accessor("email", {
header: "Email",
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
}),
columnHelper.accessor("amount", {
header: () => <div className="text-end">Amount</div>,
cell: ({ row }) => {
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(Number(row.getValue("amount")))
return <div className="text-end font-medium">{formatted}</div>
},
}),
])display column that renders a DropdownMenu per row — row.original gives the cell access to the full record for handlers like copy or delete. enableHiding: false keeps it out of the column-visibility toggle.| Status | Amount | ||
|---|---|---|---|
success | ken99@example.com | $316.00 | |
success | Abe45@example.com | $242.00 | |
processing | Monserrat44@example.com | $837.00 | |
success | Silas22@example.com | $874.00 | |
failed | carmella@example.com | $721.00 |
import { DotsThreeIcon } from "@phosphor-icons/react"
import { Button } from "@workspace/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu"
const columns = columnHelper.columns([
// ...
columnHelper.display({
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const payment = row.original
return (
<div className="text-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<DotsThreeIcon />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(payment.id)}
>
Copy payment ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View customer</DropdownMenuItem>
<DropdownMenuItem>View payment details</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
}),
])rowPaginationFeature and the paginated row model registered, rows split into pages of 10 automatically — set initialState.pagination to change the page size (3 here). Wire controls to table.previousPage() and table.nextPage().| Status | Amount | |
|---|---|---|
success | ken99@example.com | $316.00 |
success | Abe45@example.com | $242.00 |
processing | Monserrat44@example.com | $837.00 |
const table = useTable({
features,
data,
columns,
initialState: { pagination: { pageIndex: 0, pageSize: 3 } },
})
<div className="flex items-center justify-end gap-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>SortingState in React state and pass it back through onSortingChange and state.sorting. Turn a header into a sort control by rendering a ghost Button that calls column.toggleSorting() — click Email to flip between ascending and descending.| Status | Amount | |
|---|---|---|
success | ken99@example.com | $316.00 |
success | Abe45@example.com | $242.00 |
processing | Monserrat44@example.com | $837.00 |
success | Silas22@example.com | $874.00 |
failed | carmella@example.com | $721.00 |
import { type SortingState } from "@tanstack/react-table"
import { ArrowsDownUpIcon } from "@phosphor-icons/react"
const [sorting, setSorting] = React.useState<SortingState>([])
const table = useTable({
features,
data,
columns,
onSortingChange: setSorting,
state: { sorting },
})
// In the columns definition:
columnHelper.accessor("email", {
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowsDownUpIcon />
</Button>
),
})table.getColumn("email") via getFilterValue / setFilterValue. The includesString filter function registered in the features object does the matching.| Status | Amount | |
|---|---|---|
success | ken99@example.com | $316.00 |
success | Abe45@example.com | $242.00 |
processing | Monserrat44@example.com | $837.00 |
success | Silas22@example.com | $874.00 |
failed | carmella@example.com | $721.00 |
import { type ColumnFiltersState } from "@tanstack/react-table"
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const table = useTable({
features,
data,
columns,
onColumnFiltersChange: setColumnFilters,
state: { columnFilters },
})
<Input
placeholder="Filter emails..."
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("email")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>columnVisibilityFeature powers a Columns dropdown: list every column where getCanHide() is true as a DropdownMenuCheckboxItem toggling column.toggleVisibility().| Status | Amount | |
|---|---|---|
success | ken99@example.com | $316.00 |
success | Abe45@example.com | $242.00 |
processing | Monserrat44@example.com | $837.00 |
success | Silas22@example.com | $874.00 |
failed | carmella@example.com | $721.00 |
import { type ColumnVisibilityState } from "@tanstack/react-table"
import { CaretDownIcon } from "@phosphor-icons/react"
const [columnVisibility, setColumnVisibility] =
React.useState<ColumnVisibilityState>({})
const table = useTable({
features,
data,
columns,
onColumnVisibilityChange: setColumnVisibility,
state: { columnVisibility },
})
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Columns <CaretDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>display column of Checkboxes drives rowSelectionFeature — the header toggles the whole page, each cell toggles its row, and selected rows tint via data-state="selected" on TableRow. Read counts from getFilteredSelectedRowModel().| Status | Amount | ||
|---|---|---|---|
success | ken99@example.com | $316.00 | |
success | Abe45@example.com | $242.00 | |
processing | Monserrat44@example.com | $837.00 | |
success | Silas22@example.com | $874.00 | |
failed | carmella@example.com | $721.00 |
import { Checkbox } from "@workspace/ui/components/checkbox"
const [rowSelection, setRowSelection] = React.useState({})
const table = useTable({
features,
data,
columns,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
})
// In the columns definition:
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
})
// Below the table:
<div className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>DataTable into a reusable component that takes columns and data props.app
└── payments
├── columns.tsx // "use client" — column definitions
├── data-table-features.ts // shared tableFeatures() object
├── data-table.tsx // "use client" — <DataTable /> component
└── page.tsx // server component — fetches dataTokens used
16 design tokens consumed by the Data Table pattern.
--muted
Muted
--foreground
Foreground
--muted-foreground
Muted Foreground
--border
Border
--input
Input
--ring
Ring
--primary
Primary
--primary-foreground
Primary Foreground
--accent
Accent
--accent-foreground
Accent Foreground
--popover
Popover
--popover-foreground
Popover Foreground
--text-sm
Text Small (14px)
--font-weight-medium
Font weight Medium (500)
--radius-md
Radius 0.8× (8px)
--spacing
Spacing unit (4px)