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 TableProps extends React.ComponentPropsWithRef<'table'> {
density?: 'sm' | 'md';
}
const Table = ({ ref, className, density, ...props }: TableProps) => {
return (
<div className="relative w-full overflow-x-auto">
<table
ref={ref}
className={cn(tableStyle({ density }), className)}
{...props}
/>
</div>
);
};
interface TableHeaderProps extends React.ComponentPropsWithRef<'thead'> {
/**
* Pin the header to the top of the nearest scroll container. The container
* must have a defined height for `position: sticky` to engage.
*/
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/[0.02] font-medium',
className
)}
{...props}
/>
);
};
const TableRow = ({
ref,
className,
...props
}: React.ComponentPropsWithRef<'tr'>) => {
return (
<tr
ref={ref}
className={cn(
'transition-colors',
'data-selected:bg-foreground/[0.04]',
'hover:bg-foreground/[0.02]',
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'> {
/**
* Current sort direction for this column. `false` means unsorted.
*/
sort: 'asc' | 'desc' | false;
/**
* Called when the consumer clicks the header. Implement the
* `unsorted → asc → desc → unsorted` (or whichever) cycle yourself.
*/
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, {
Header: TableHeader,
Body: TableBody,
Footer: TableFooter,
Row: TableRow,
Head: TableHead,
Cell: TableCell,
Caption: TableCaption,
SortableHead: TableSortableHead,
});
export { CompoundTable as Table }; Anatomy
<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>
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 wraps the rendered <table> in a relative w-full overflow-x-auto div so wide tables scroll horizontally on narrow viewports without extra ceremony.
API Reference
Table
Extends the table element. Renders inside an overflow-aware wrapper.
| 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>
);
} 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>
<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>
<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.Headerrequires the table’s scroll container to have a fixed height. The default wrapper around<table>only handles horizontal overflow — wrap it in your ownmax-h-*container for vertical sticky behavior.
-
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