Table
Semantic table primitive. Compose with TanStack Table for sorting, selection, filtering, and pagination.
| Invoice | Client | Status | Amount |
|---|---|---|---|
| INV-001 | Acme Corp | Paid | $1,250.00 |
| INV-002 | Globex | Pending | $480.00 |
| INV-003 | Initech | Paid | $3,200.00 |
| INV-004 | Umbrella | Overdue | $920.00 |
| INV-005 | Soylent | Pending | $1,675.00 |
| Total | $7,525.00 | ||
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
const invoices = [
{ id: "INV-001", client: "Acme Corp", status: "Paid", amount: 1250 },
{ id: "INV-002", client: "Globex", status: "Pending", amount: 480 },
{ id: "INV-003", client: "Initech", status: "Paid", amount: 3200 },
{ id: "INV-004", client: "Umbrella", status: "Overdue", amount: 920 },
{ id: "INV-005", client: "Soylent", status: "Pending", amount: 1675 },
];
const formatAmount = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
export default function TablePreview() {
const total = invoices.reduce((sum, i) => sum + i.amount, 0);
return (
<Table>
<Table.Caption>Recent invoices</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Invoice</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head align="end">Amount</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{invoices.map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Cell className="font-medium">{invoice.id}</Table.Cell>
<Table.Cell>{invoice.client}</Table.Cell>
<Table.Cell>{invoice.status}</Table.Cell>
<Table.Cell align="end">{formatAmount(invoice.amount)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.Cell colSpan={3}>Total</Table.Cell>
<Table.Cell align="end">{formatAmount(total)}</Table.Cell>
</Table.Row>
</Table.Footer>
</Table>
);
}Dependencies
Source Code
import { CaretDownIcon, CaretUpDownIcon, CaretUpIcon } from "@phosphor-icons/react/dist/ssr";
import { cn, cva } from "@/lib/utils/classnames";
const tableStyle = cva({
base: [
"w-full caption-bottom border-collapse text-sm",
"[--table-cell-px:--spacing(4)] [--table-cell-py:--spacing(3)]",
],
variants: {
density: {
sm: "[--table-cell-py:--spacing(2)]",
md: "[--table-cell-py:--spacing(3)]",
},
},
defaultVariants: {
density: "md",
},
});
interface TableContainerProps extends React.ComponentPropsWithRef<"div"> {
bordered?: boolean;
}
const TableContainer = ({ ref, className, bordered = true, ...props }: TableContainerProps) => {
return (
<div
ref={ref}
className={cn(
"relative w-full overflow-x-auto",
bordered && "rounded-lg border border-border shadow-xs",
className,
)}
{...props}
/>
);
};
interface TableProps extends React.ComponentPropsWithRef<"table"> {
density?: "sm" | "md";
}
const Table = ({ ref, className, density, ...props }: TableProps) => {
return <table ref={ref} className={cn(tableStyle({ density }), className)} {...props} />;
};
interface TableHeaderProps extends React.ComponentPropsWithRef<"thead"> {
sticky?: boolean;
}
const TableHeader = ({ ref, className, sticky, ...props }: TableHeaderProps) => {
return (
<thead
ref={ref}
className={cn(
"[&_tr]:border-border [&_tr]:border-b",
sticky && "sticky top-0 z-10 bg-background",
className,
)}
{...props}
/>
);
};
const TableBody = ({ ref, className, ...props }: React.ComponentPropsWithRef<"tbody">) => {
return (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0 [&_tr]:border-border [&_tr]:border-b", className)}
{...props}
/>
);
};
const TableFooter = ({ ref, className, ...props }: React.ComponentPropsWithRef<"tfoot">) => {
return (
<tfoot
ref={ref}
className={cn("border-border border-t bg-foreground/2 font-medium", className)}
{...props}
/>
);
};
const TableRow = ({ ref, className, ...props }: React.ComponentPropsWithRef<"tr">) => {
return (
<tr
ref={ref}
className={cn("transition-colors", "data-selected:bg-accent/4", className)}
{...props}
/>
);
};
const cellAlignStyle = cva({
variants: {
align: {
start: "text-start",
center: "text-center",
end: "text-end",
},
},
defaultVariants: {
align: "start",
},
});
interface TableHeadProps extends Omit<React.ComponentPropsWithRef<"th">, "align"> {
align?: "start" | "center" | "end";
}
const TableHead = ({ ref, className, align, ...props }: TableHeadProps) => {
return (
<th
ref={ref}
className={cn(
"h-10 px-(--table-cell-px) align-middle font-medium text-foreground-secondary",
"whitespace-nowrap",
cellAlignStyle({ align }),
className,
)}
{...props}
/>
);
};
interface TableCellProps extends Omit<React.ComponentPropsWithRef<"td">, "align"> {
align?: "start" | "center" | "end";
}
const TableCell = ({ ref, className, align, ...props }: TableCellProps) => {
return (
<td
ref={ref}
className={cn(
"px-(--table-cell-px) py-(--table-cell-py) align-middle",
cellAlignStyle({ align }),
className,
)}
{...props}
/>
);
};
const TableCaption = ({ ref, className, ...props }: React.ComponentPropsWithRef<"caption">) => {
return (
<caption
ref={ref}
className={cn("mt-4 text-foreground-secondary text-sm", className)}
{...props}
/>
);
};
interface TableSortableHeadProps extends Omit<TableHeadProps, "onClick"> {
sort: "asc" | "desc" | false;
onSort: (event?: React.MouseEvent<HTMLButtonElement>) => void;
}
const TableSortableHead = ({
className,
children,
sort,
onSort,
...props
}: TableSortableHeadProps) => {
const ariaSort = sort === "asc" ? "ascending" : sort === "desc" ? "descending" : "none";
const Icon = sort === "asc" ? CaretUpIcon : sort === "desc" ? CaretDownIcon : CaretUpDownIcon;
return (
<TableHead
aria-sort={ariaSort}
data-sort={sort || undefined}
className={cn("p-0", className)}
{...props}
>
<button
type="button"
onClick={onSort}
className={cn(
"group/sort flex h-10 w-full items-center gap-1.5 px-(--table-cell-px) py-0",
"cursor-pointer text-start font-medium text-foreground-secondary",
"transition-colors hover:text-foreground",
"focus-visible:ring-(length:--ring-width) outline-none ring-ring",
"data-[align=end]:justify-end data-[align=center]:justify-center",
)}
data-align={props.align}
>
{children}
<Icon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 transition-opacity",
sort ? "opacity-100" : "opacity-40 group-hover/sort:opacity-70",
)}
/>
</button>
</TableHead>
);
};
const CompoundTable = Object.assign(Table, {
Container: TableContainer,
Header: TableHeader,
Body: TableBody,
Footer: TableFooter,
Row: TableRow,
Head: TableHead,
Cell: TableCell,
Caption: TableCaption,
SortableHead: TableSortableHead,
});
export { CompoundTable as Table };Anatomy
<Table.Container>
<Table>
<Table.Caption />
<Table.Header>
<Table.Row>
<Table.Head />
<Table.SortableHead sort={false} onSort={() => {}} />
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell />
</Table.Row>
</Table.Body>
<Table.Footer />
</Table>
</Table.Container>
The compound parts are purely structural — they map one-to-one onto the underlying HTML table elements (<table>, <thead>, <tbody>, <tfoot>, <tr>, <th>, <td>, <caption>). State for sorting, selection, filtering, and pagination lives with the consumer. For dashboards with non-trivial data, compose with TanStack Table — see the recipe below.
Table renders the bare <table> element. Wrap it in Table.Container when you want a bordered card, horizontal overflow on narrow viewports, or a sticky header — the container is the scroll context, so it owns the border, the rounding, and any height constraint. A plain <Table> with no container is perfectly valid for a table that should flow inline with the page.
API Reference
Table.Container
Optional div wrapper that becomes the table’s scroll container — it handles horizontal overflow and, with a max-h-*, vertical scrolling for a sticky header. Pin a height here, not on an outer element, since position: sticky engages against the nearest scroll container. Extends the div element.
| Prop | Default | Type | Description |
|---|---|---|---|
bordered | true | boolean | Wrap the table in a bordered card (rounded border + subtle shadow). |
Table
Extends the table element. Renders the bare <table> — wrap it in Table.Container for borders, overflow, or sticky headers.
| Prop | Default | Type | Description |
|---|---|---|---|
density | "md" | "sm""md" | Vertical padding of body cells. |
Table.Header
Extends the thead element.
| Prop | Default | Type | Description |
|---|---|---|---|
sticky | - | boolean | Pin the header to the top of the nearest scroll container. Requires the container to have a defined height for `position: sticky` to engage. |
Table.Body
Extends the tbody element. Adds bottom borders between rows.
Table.Footer
Extends the tfoot element. Use for totals or summary rows.
Table.Row
Extends the tr element. Set data-selected on the row to apply the selected style — the data-table recipe wires this up automatically.
Table.Head
Extends the th element.
| Prop | Default | Type | Description |
|---|---|---|---|
align | "start" | "start""center""end" | Text alignment. Use 'end' for numeric columns. |
Table.Cell
Extends the td element.
| Prop | Default | Type |
|---|---|---|
align | "start" | "start""center""end" |
Table.Caption
Extends the caption element. Renders below the table by default (caption-side: bottom).
Table.SortableHead
Renders a <th> containing a sort button with caret affordance and aria-sort wiring. State is fully controlled — pass the current direction and handle the cycle yourself.
| Prop | Default | Type | Description |
|---|---|---|---|
sort | - | "asc""desc"false | Current sort direction. `false` means unsorted. |
onSort | - | () => void | Called when the header is activated. Implement the unsorted → asc → desc cycle in the parent. |
align | "start" | "start""center""end" |
Examples
Basic
A static table with a footer summary.
| Invoice | Client | Status | Amount |
|---|---|---|---|
| INV-001 | Acme Corp | Paid | $1,250.00 |
| INV-002 | Globex | Pending | $480.00 |
| INV-003 | Initech | Paid | $3,200.00 |
| INV-004 | Umbrella | Overdue | $920.00 |
| INV-005 | Soylent | Pending | $1,675.00 |
| Total | $7,525.00 | ||
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
const invoices = [
{ id: "INV-001", client: "Acme Corp", status: "Paid", amount: 1250 },
{ id: "INV-002", client: "Globex", status: "Pending", amount: 480 },
{ id: "INV-003", client: "Initech", status: "Paid", amount: 3200 },
{ id: "INV-004", client: "Umbrella", status: "Overdue", amount: 920 },
{ id: "INV-005", client: "Soylent", status: "Pending", amount: 1675 },
];
const formatAmount = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
export default function TablePreview() {
const total = invoices.reduce((sum, i) => sum + i.amount, 0);
return (
<Table>
<Table.Caption>Recent invoices</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Invoice</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head align="end">Amount</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{invoices.map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Cell className="font-medium">{invoice.id}</Table.Cell>
<Table.Cell>{invoice.client}</Table.Cell>
<Table.Cell>{invoice.status}</Table.Cell>
<Table.Cell align="end">{formatAmount(invoice.amount)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.Cell colSpan={3}>Total</Table.Cell>
<Table.Cell align="end">{formatAmount(total)}</Table.Cell>
</Table.Row>
</Table.Footer>
</Table>
);
}Sortable headers
Local state drives the sort direction. The header cycles asc → desc on each click.
| Ana Costa | Designer | 2024-02-12 |
| Bruno Silva | Engineer | 2023-08-30 |
| Carla Mendes | PM | 2025-01-04 |
| Diogo Pinto | Engineer | 2022-11-19 |
| Elisa Faria | Designer | 2024-09-22 |
import { useMemo, useState } from "react";
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
type SortKey = "name" | "role" | "joined";
type SortDir = "asc" | "desc";
const team = [
{ name: "Ana Costa", role: "Designer", joined: "2024-02-12" },
{ name: "Bruno Silva", role: "Engineer", joined: "2023-08-30" },
{ name: "Carla Mendes", role: "PM", joined: "2025-01-04" },
{ name: "Diogo Pinto", role: "Engineer", joined: "2022-11-19" },
{ name: "Elisa Faria", role: "Designer", joined: "2024-09-22" },
];
export default function TableSortablePreview() {
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const sorted = useMemo(() => {
return [...team].sort((a, b) => {
const cmp = a[sortKey].localeCompare(b[sortKey]);
return sortDir === "asc" ? cmp : -cmp;
});
}, [sortKey, sortDir]);
const sortFor = (key: SortKey) => (sortKey === key ? sortDir : (false as const));
const handleSort = (key: SortKey) => () => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
return (
<Table>
<Table.Header>
<Table.Row>
<Table.SortableHead sort={sortFor("name")} onSort={handleSort("name")}>
Name
</Table.SortableHead>
<Table.SortableHead sort={sortFor("role")} onSort={handleSort("role")}>
Role
</Table.SortableHead>
<Table.SortableHead sort={sortFor("joined")} onSort={handleSort("joined")}>
Joined
</Table.SortableHead>
</Table.Row>
</Table.Header>
<Table.Body>
{sorted.map((member) => (
<Table.Row key={member.name}>
<Table.Cell className="font-medium">{member.name}</Table.Cell>
<Table.Cell>{member.role}</Table.Cell>
<Table.Cell>{member.joined}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
);
}Row selection
Header checkbox toggles all rows; selected rows get a data-selected attribute that the row style picks up.
| Task | Priority | Assignee | |
|---|---|---|---|
| Review PR #482 | High | Ana | |
| Update onboarding copy | Low | Bruno | |
| Fix flaky checkout test | High | Carla | |
| Audit color tokens | Medium | Diogo |
import { useState } from "react";
import { Checkbox } from "@/components/checkbox";
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
const tasks = [
{ id: "t-1", title: "Review PR #482", priority: "High", assignee: "Ana" },
{
id: "t-2",
title: "Update onboarding copy",
priority: "Low",
assignee: "Bruno",
},
{
id: "t-3",
title: "Fix flaky checkout test",
priority: "High",
assignee: "Carla",
},
{
id: "t-4",
title: "Audit color tokens",
priority: "Medium",
assignee: "Diogo",
},
];
export default function TableWithSelectionPreview() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = selected.size === tasks.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => {
setSelected(allSelected ? new Set() : new Set(tasks.map((t) => t.id)));
};
const toggleOne = (id: string) => () => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<Table>
<Table.Header>
<Table.Row>
<Table.Head className="w-10">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={toggleAll}
aria-label="Select all rows"
/>
</Table.Head>
<Table.Head>Task</Table.Head>
<Table.Head>Priority</Table.Head>
<Table.Head>Assignee</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{tasks.map((task) => {
const isSelected = selected.has(task.id);
return (
<Table.Row key={task.id} data-selected={isSelected || undefined}>
<Table.Cell>
<Checkbox
checked={isSelected}
onChange={toggleOne(task.id)}
aria-label={`Select ${task.title}`}
/>
</Table.Cell>
<Table.Cell className="font-medium">{task.title}</Table.Cell>
<Table.Cell>{task.priority}</Table.Cell>
<Table.Cell>{task.assignee}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
);
}Sticky header
Wrap the table in Table.Container with a max-h-* and set sticky on Table.Header. The container is the scroll context, so the header pins to its top as the body scrolls.
| Commit | Author | Message |
|---|---|---|
| 4a2f1c | Ana Costa | Fix sticky header scroll context |
| 4a30ef | Bruno Silva | Add Table.Container part |
| 4a32c2 | Carla Mendes | Bump dependencies |
| 4a3495 | Diogo Pinto | Tidy up docs |
| 4a3668 | Ana Costa | Fix sticky header scroll context |
| 4a383b | Bruno Silva | Add Table.Container part |
| 4a3a0e | Carla Mendes | Bump dependencies |
| 4a3be1 | Diogo Pinto | Tidy up docs |
| 4a3db4 | Ana Costa | Fix sticky header scroll context |
| 4a3f87 | Bruno Silva | Add Table.Container part |
| 4a415a | Carla Mendes | Bump dependencies |
| 4a432d | Diogo Pinto | Tidy up docs |
| 4a4500 | Ana Costa | Fix sticky header scroll context |
| 4a46d3 | Bruno Silva | Add Table.Container part |
| 4a48a6 | Carla Mendes | Bump dependencies |
| 4a4a79 | Diogo Pinto | Tidy up docs |
| 4a4c4c | Ana Costa | Fix sticky header scroll context |
| 4a4e1f | Bruno Silva | Add Table.Container part |
| 4a4ff2 | Carla Mendes | Bump dependencies |
| 4a51c5 | Diogo Pinto | Tidy up docs |
| 4a5398 | Ana Costa | Fix sticky header scroll context |
| 4a556b | Bruno Silva | Add Table.Container part |
| 4a573e | Carla Mendes | Bump dependencies |
| 4a5911 | Diogo Pinto | Tidy up docs |
| 4a5ae4 | Ana Costa | Fix sticky header scroll context |
| 4a5cb7 | Bruno Silva | Add Table.Container part |
| 4a5e8a | Carla Mendes | Bump dependencies |
| 4a605d | Diogo Pinto | Tidy up docs |
| 4a6230 | Ana Costa | Fix sticky header scroll context |
| 4a6403 | Bruno Silva | Add Table.Container part |
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
const commits = Array.from({ length: 30 }, (_, i) => ({
hash: (0x4a2f1c + i * 0x1d3).toString(16).slice(0, 7),
author: ["Ana Costa", "Bruno Silva", "Carla Mendes", "Diogo Pinto"][i % 4],
message: [
"Fix sticky header scroll context",
"Add Table.Container part",
"Bump dependencies",
"Tidy up docs",
][i % 4],
}));
export default function TableStickyPreview() {
return (
<Table.Container className="max-h-72">
<Table>
<Table.Header sticky>
<Table.Row>
<Table.Head>Commit</Table.Head>
<Table.Head>Author</Table.Head>
<Table.Head>Message</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{commits.map((commit) => (
<Table.Row key={commit.hash}>
<Table.Cell className="font-mono">{commit.hash}</Table.Cell>
<Table.Cell>{commit.author}</Table.Cell>
<Table.Cell>{commit.message}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Table.Container>
);
}Data table (with TanStack Table)
For sorting, filtering, selection, and pagination together, compose Table with @tanstack/react-table. This is not a hard dependency of the primitive — you only need it when you want the engine.
pnpm add @tanstack/react-table
The recipe:
- Define
ColumnDef<T>[]—accessorKey,header,cell, optionalmetafor cell alignment. - Call
useReactTablewith the row models you need (getCoreRowModel,getSortedRowModel,getPaginationRowModel). - Map
table.getHeaderGroups()intoTable.Head/Table.SortableHeadbased oncolumn.getCanSort(). - Map
table.getRowModel().rowsintoTable.Row/Table.Cell. UseflexRender(cell.column.columnDef.cell, cell.getContext())for the cell body. - Wire
Paginationtotable.getPageCount(),table.previousPage(), andtable.nextPage().
| #1024 | Acme Corp | Shipped | 2026-04-22 | $2,480.00 | |
| #1025 | Globex | Pending | 2026-04-22 | $415.00 | |
| #1026 | Initech | Delivered | 2026-04-21 | $8,120.00 | |
| #1027 | Umbrella | Refunded | 2026-04-20 | $320.00 | |
| #1028 | Soylent | Shipped | 2026-04-20 | $1,675.00 |
0 of 12 selected
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { Checkbox } from "@/components/checkbox";
import { Pagination, usePagination } from "@/components/pagination";
import { Table } from "@/components/table";
export const meta = { layout: "padded" } as const;
type Order = {
id: string;
customer: string;
status: "Pending" | "Shipped" | "Delivered" | "Refunded";
total: number;
createdAt: string;
};
const orders: Order[] = [
{
id: "#1024",
customer: "Acme Corp",
status: "Shipped",
total: 2480,
createdAt: "2026-04-22",
},
{
id: "#1025",
customer: "Globex",
status: "Pending",
total: 415,
createdAt: "2026-04-22",
},
{
id: "#1026",
customer: "Initech",
status: "Delivered",
total: 8120,
createdAt: "2026-04-21",
},
{
id: "#1027",
customer: "Umbrella",
status: "Refunded",
total: 320,
createdAt: "2026-04-20",
},
{
id: "#1028",
customer: "Soylent",
status: "Shipped",
total: 1675,
createdAt: "2026-04-20",
},
{
id: "#1029",
customer: "Massive Dynamic",
status: "Pending",
total: 590,
createdAt: "2026-04-19",
},
{
id: "#1030",
customer: "Tyrell",
status: "Delivered",
total: 4250,
createdAt: "2026-04-18",
},
{
id: "#1031",
customer: "Wayne Ent.",
status: "Shipped",
total: 980,
createdAt: "2026-04-18",
},
{
id: "#1032",
customer: "Stark Ind.",
status: "Pending",
total: 1340,
createdAt: "2026-04-17",
},
{
id: "#1033",
customer: "Wonka",
status: "Delivered",
total: 215,
createdAt: "2026-04-16",
},
{
id: "#1034",
customer: "Cyberdyne",
status: "Shipped",
total: 6700,
createdAt: "2026-04-15",
},
{
id: "#1035",
customer: "Hooli",
status: "Refunded",
total: 110,
createdAt: "2026-04-15",
},
];
const formatAmount = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
export default function TableDataTablePreview() {
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Order>[]>(
() => [
{
id: "select",
size: 40,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={(e) => table.toggleAllPageRowsSelected(!!e.currentTarget.checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label={`Select order ${row.original.id}`}
checked={row.getIsSelected()}
onChange={(e) => row.toggleSelected(!!e.currentTarget.checked)}
/>
),
enableSorting: false,
},
{
accessorKey: "id",
header: "Order",
cell: (info) => <span className="font-medium">{info.getValue<string>()}</span>,
},
{ accessorKey: "customer", header: "Customer" },
{ accessorKey: "status", header: "Status" },
{
accessorKey: "createdAt",
header: "Created",
},
{
accessorKey: "total",
header: "Total",
cell: (info) => formatAmount(info.getValue<number>()),
meta: { align: "end" as const },
},
],
[],
);
const table = useReactTable({
data: orders,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 5 } },
});
const pageCount = table.getPageCount();
const pageIndex = table.getState().pagination.pageIndex;
const items = usePagination({ page: pageIndex + 1, count: pageCount });
const selectedCount = table.getSelectedRowModel().rows.length;
return (
<div className="flex flex-col gap-4">
<Table.Container>
<Table>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const align = (
header.column.columnDef.meta as
| { align?: "start" | "center" | "end" }
| undefined
)?.align;
const canSort = header.column.getCanSort();
const sort = header.column.getIsSorted();
if (canSort) {
return (
<Table.SortableHead
key={header.id}
align={align}
sort={sort === false ? false : sort}
onSort={header.column.getToggleSortingHandler() ?? (() => {})}
style={header.getSize() !== 150 ? { width: header.getSize() } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Table.SortableHead>
);
}
return (
<Table.Head
key={header.id}
align={align}
style={header.getSize() !== 150 ? { width: header.getSize() } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</Table.Head>
);
})}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.length === 0 ? (
<Table.Row>
<Table.Cell
colSpan={columns.length}
align="center"
className="h-24 text-foreground-secondary"
>
No results.
</Table.Cell>
</Table.Row>
) : (
table.getRowModel().rows.map((row) => (
<Table.Row key={row.id} data-selected={row.getIsSelected() || undefined}>
{row.getVisibleCells().map((cell) => {
const align = (
cell.column.columnDef.meta as
| { align?: "start" | "center" | "end" }
| undefined
)?.align;
return (
<Table.Cell key={cell.id} align={align}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>
);
})}
</Table.Row>
))
)}
</Table.Body>
</Table>
</Table.Container>
<div className="flex items-center justify-between gap-4">
<p className="text-foreground-secondary text-sm">
{selectedCount} of {table.getFilteredRowModel().rows.length} selected
</p>
<Pagination className="w-auto">
<Pagination.List>
<Pagination.Item>
<Pagination.Previous
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
/>
</Pagination.Item>
{items.map((item) =>
item.type === "page" ? (
<Pagination.Item key={item.key}>
<Pagination.Link
isActive={item.selected}
onClick={() => table.setPageIndex(item.value - 1)}
>
{item.value}
</Pagination.Link>
</Pagination.Item>
) : (
<Pagination.Item key={item.key}>
<Pagination.Ellipsis />
</Pagination.Item>
),
)}
<Pagination.Item>
<Pagination.Next
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
/>
</Pagination.Item>
</Pagination.List>
</Pagination>
</div>
</div>
);
}Best Practices
-
Scope:
- This primitive is the styled
<table>— not a data engine. For ≤1k rows in a dashboard, compose with TanStack Table as shown above. - For 100k+ rows you’ll want virtualization. Reach for
@tanstack/react-virtualon top of TanStack Table, or switch to a dedicated data grid (AG Grid, LyteNyte, MUI X DataGrid Pro). - Out of scope here: column resize/reorder, pinning, grouping, range selection, inline editing. If you need them, compose them at the call site or pick a heavier tool.
- This primitive is the styled
-
Alignment:
- Use
align="end"on numeric columns and their header. It makes magnitudes scannable. - Keep text columns left-aligned. Centered text in tables is rarely the right choice.
- Use
-
Accessibility:
- Use
<Table.Caption>to describe the table’s purpose — it’s announced by screen readers. Table.SortableHeadsetsaria-sortautomatically; don’t override it.- When using checkboxes for selection, set
aria-labelon each — column headers alone aren’t enough context. - For a row that acts as a header for its row (e.g. a name column in a record list), you can render
<Table.Head scope="row">insideTable.Body.
- Use
-
Sticky headers:
stickyonTable.Headerpins to the nearest scroll container, which must have a bounded height forposition: stickyto engage. Wrap the table inTable.Containerand put themax-h-*on the container itself — not on an outer element, which would scroll the whole block (header included) instead of just the body.
-
Empty and loading states:
- Render a single full-width row with
colSpan={columns.length}for “No results.” - For loading, replace cell contents with
<Skeleton />rather than swapping the table for a spinner — preserves layout and prevents jumpy reflows.
- Render a single full-width row with
Previous
Switch
Next
Tabs