Skip to main content

Data Table

Table, filter, column picker, pagination and row selection in one piece. Sorting, filtering and paging run in plain React over the rows you pass in.

INV-1042
Acme Corpbilling@acmecorp.com
paid$250.00
INV-1043
Globexbilling@globex.com
pending$981.00
INV-1044
Initechbilling@initech.com
overdue$1,712.00
INV-1045
Umbrellabilling@umbrella.com
paid$2,443.00
INV-1046
Hoolibilling@hooli.com
pending$3,174.00
INV-1047
Vehement Capitalbilling@vehementcapital.com
overdue$3,905.00
0 of 23 selected
Page 1 of 4
1'use client';
2
3import { Badge } from '@/components/ui/badge';
4import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
5
6type Invoice = {
7 id: string;
8 customer: string;
9 email: string;
10 amount: number;
11 status: 'paid' | 'pending' | 'overdue';
12 issued: Date;
13};
14
15const CUSTOMERS = [
16 'Acme Corp',
17 'Globex',
18 'Initech',
19 'Umbrella',
20 'Hooli',
21 'Vehement Capital',
22 'Stark Industries',
23 'Wayne Enterprises',
24 'Wonka',
25 'Cyberdyne',
26 'Tyrell',
27 'Soylent',
28 'Massive Dynamic',
29 'Aperture',
30 'Sirius Cybernetics',
31 'Oscorp',
32 'Gringotts',
33 'Prestige Worldwide',
34 'Dunder Mifflin',
35 'Los Pollos Hermanos',
36 'Pied Piper',
37 'Bluth Company',
38 'Sterling Cooper',
39];
40
41const STATUSES: Invoice['status'][] = ['paid', 'pending', 'overdue'];
42
43const INVOICES: Invoice[] = CUSTOMERS.map((customer, index) => ({
44 id: `INV-${String(1042 + index)}`,
45 customer,
46 email: `billing@${customer.toLowerCase().replace(/[^a-z]/g, '')}.com`,
47 amount: 250 + ((index * 731) % 4800),
48 status: STATUSES[(index * 7) % 3] ?? 'paid',
49 issued: new Date(2026, 8, 24 - index),
50}));
51
52const TONE: Record<Invoice['status'], 'success' | 'warning' | 'destructive'> = {
53 paid: 'success',
54 pending: 'warning',
55 overdue: 'destructive',
56};
57
58const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
59const date = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
60
61const COLUMNS: DataTableColumn<Invoice>[] = [
62 {
63 id: 'id',
64 header: 'Invoice',
65 cell: (row) => <span className="font-mono">{row.id}</span>,
66 sortable: true,
67 sortValue: (row) => row.id,
68 filterValue: (row) => row.id,
69 width: '7rem',
70 },
71 {
72 id: 'customer',
73 header: 'Customer',
74 cell: (row) => (
75 <div className="flex flex-col">
76 <span className="text-foreground font-medium">{row.customer}</span>
77 <span className="text-muted-foreground">{row.email}</span>
78 </div>
79 ),
80 sortable: true,
81 sortValue: (row) => row.customer,
82 filterValue: (row) => `${row.customer} ${row.email}`,
83 },
84 {
85 id: 'status',
86 header: 'Status',
87 cell: (row) => <Badge variant={TONE[row.status]}>{row.status}</Badge>,
88 sortable: true,
89 sortValue: (row) => row.status,
90 filterValue: (row) => row.status,
91 },
92 {
93 id: 'issued',
94 header: 'Issued',
95 cell: (row) => date.format(row.issued),
96 sortable: true,
97 sortValue: (row) => row.issued,
98 hidden: true,
99 },
100 {
101 id: 'amount',
102 header: 'Amount',
103 cell: (row) => money.format(row.amount),
104 sortable: true,
105 sortValue: (row) => row.amount,
106 align: 'end',
107 width: '8rem',
108 },
109];
110
111export function Default() {
112 return (
113 <DataTable
114 data={INVOICES}
115 columns={COLUMNS}
116 getRowId={(row) => row.id}
117 pageSize={6}
118 selectable
119 filterPlaceholder="Filter invoices…"
120 emptyTitle="No invoices match"
121 emptyDescription="Try a customer name, an invoice number or a status."
122 />
123 );
124}

Installation

pnpm dlx nachui add data-table

Anatomy

1import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
1const columns: DataTableColumn<Invoice>[] = [
2 {
3 id: 'id',
4 header: 'Invoice',
5 cell: (row) => row.id,
6 sortable: true,
7 sortValue: (row) => row.id,
8 },
9 {
10 id: 'customer',
11 header: 'Customer',
12 cell: (row) => row.customer,
13 sortable: true,
14 sortValue: (row) => row.customer,
15 },
16 {
17 id: 'amount',
18 header: 'Amount',
19 cell: (row) => money(row.amount),
20 sortable: true,
21 sortValue: (row) => row.amount,
22 align: 'end',
23 },
24];
25
26<DataTable data={invoices} columns={columns} getRowId={(row) => row.id} pageSize={10} selectable />;
With no children the table lays itself out: the filter and the column picker on top, the table in the middle, the selection count and the pager at the bottom. Everything runs on the array you pass in, so there is no fetching, no adapter and no library underneath.
Pass children to keep only the parts you want. Every part reads the same context, so a table alone still filters, sorts and pages through whatever you wire to useDataTable.
1<DataTable data={rows} columns={columns} getRowId={(row) => row.id}>
2 <DataTable.Content />
3</DataTable>

Composition

A hybrid is visible composition. Open data-table.tsx and these are the elements it is made of, in this order:
DataTable
├── DataTable.Toolbar
│ ├── Input → the filter
│ └── DropdownMenu → column visibility
├── DataTable.Content
│ └── Table
│ ├── Checkbox → select all, select row
│ └── Empty → no rows
└── DataTable.Footer
└── Pagination

Column shape

A column says how to read the row and how to show it. cell renders, sortValue and filterValue are what sorting and filtering compare. When a column has no filterValue the table falls back to sortValue, then to the cell if it is plain text.
FieldTypeDescription
idstringStable key, also the name in sort
headerReactNodeHeader cell, used as the label in the column menu
cell(row: T) => ReactNodeBody cell
sortablebooleanTurns the header into a sort button
sortValue(row: T) => string | number | DateValue compared when sorting
filterValue(row: T) => stringText searched by the filter
align'start' | 'end'end right-aligns and sets tabular numerals
hiddenbooleanHidden at first, still available in the menu
widthstringAny CSS width for the header cell

Sorting

Click a sortable header to sort ascending, again for descending, a third time to clear. The sort is stable, so rows that tie keep the order they came in. The header carries aria-sort for assistive tech.

Selection

Set selectable for a checkbox column. The header box selects the current page, and turns indeterminate when only some of it is selected. Selection is a set of row ids from getRowId, so it survives sorting, filtering and paging. Read it from onSelectedChange or control it with selected.

Controlled state

Filter, sort and selection are uncontrolled by default. Pass filter, sort or selected with their change handlers to own them, for a URL, a store or a server query. The pure applyDataTable helper is what the component runs, so you can run the same logic elsewhere.
1const { rows, total, pageCount } = applyDataTable(data, {
2 columns,
3 filter: 'acme',
4 sort: { id: 'amount', direction: 'desc' },
5 page: 2,
6 pageSize: 10,
7});

Compact

Keep only the table when the toolbar and footer would be noise.
VersionDateChanges
1.4.02026-09-1812
1.3.22026-09-023
1.3.12026-08-271
1.3.02026-08-209
1.2.02026-07-3015
1'use client';
2
3import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
4
5type Release = { version: string; date: string; changes: number };
6
7const RELEASES: Release[] = [
8 { version: '1.4.0', date: '2026-09-18', changes: 12 },
9 { version: '1.3.2', date: '2026-09-02', changes: 3 },
10 { version: '1.3.1', date: '2026-08-27', changes: 1 },
11 { version: '1.3.0', date: '2026-08-20', changes: 9 },
12 { version: '1.2.0', date: '2026-07-30', changes: 15 },
13];
14
15const COLUMNS: DataTableColumn<Release>[] = [
16 {
17 id: 'version',
18 header: 'Version',
19 cell: (row) => <span className="font-mono">{row.version}</span>,
20 },
21 { id: 'date', header: 'Date', cell: (row) => row.date },
22 { id: 'changes', header: 'Changes', cell: (row) => row.changes, align: 'end' },
23];
24
25export function Compact() {
26 return (
27 <div className="w-full max-w-md">
28 <DataTable data={RELEASES} columns={COLUMNS} getRowId={(row) => row.version}>
29 <DataTable.Content />
30 </DataTable>
31 </div>
32 );
33}

API Reference

DataTable

PropTypeDefaultDescription
dataT[]-Every row, before filtering and paging
columnsDataTableColumn<T>[]-Column definitions
getRowId(row: T) => string-Stable id per row, used for keys and selection
pageSizenumber10Rows per page
selectablebooleanfalseAdds the checkbox column
selectedSet<string> | string[]-Controlled selection
onSelectedChange(selected: string[]) => void-Called when selection changes
sort{ id, direction } | null-Controlled sort
onSortChange(sort) => void-Called when sort changes
filterstring-Controlled filter text
onFilterChange(filter: string) => void-Called when the filter changes
filterPlaceholderstring'Filter…'Placeholder of the filter input
emptyTitlestring'No results'Title of the empty state
emptyDescriptionstring-Description of the empty state
labelsPartial<DataTableLabels>-Every visible string, with English defaults
childrenReactNode-Custom layout, replaces the default parts
classNamestring-Additional CSS classes

DataTable.Toolbar

PropTypeDefaultDescription
childrenReactNode-Extra actions next to the filter
classNamestring-Additional CSS classes

DataTable.Content

PropTypeDefaultDescription
classNamestring-Additional CSS classes

DataTable.Footer

PropTypeDefaultDescription
childrenReactNode-Extra content next to the selection count
classNamestring-Additional CSS classes

Helpers

SignatureDescription
applyDataTable(data, { columns, filter, sort, page, pageSize })Returns { rows, total, pageCount }, the same logic the table runs
useDataTable()The table context, for custom parts inside DataTable
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue