DataTable
freeDataTable pairs TanStack Table with the product Table primitives so sorting, selection, and paging arrive already wired.
Name | Email | Plan | Status |
|---|---|---|---|
| Aria Patel | aria@acme.io | Growth | Active |
| Diego Ramos | diego@acme.io | Scale | Active |
| Mei Lin | mei@acme.io | Starter | Trial |
| Noah Becker | noah@acme.io | Growth | Past due |
| Priya Nair | priya@acme.io | Scale | Active |
| Lucas Ferreira | lucas@acme.io | Starter | Trial |
| Hana Sato | hana@acme.io | Growth | Active |
| Owen Clarke | owen@acme.io | Starter | Active |
| Freya Larsen | freya@acme.io | Scale | Past due |
| Malik Johnson | malik@acme.io | Growth | Active |
- 1
- 2
import { useMemo, useState } from 'react';
import DataTable from '@/components/composites/DataTable';
import Tag from '@/components/ui/Tag';
import type { ColumnDef, OnSortParam } from '@/components/composites/DataTable';
type Customer = {
id: string;
name: string;
email: string;
plan: 'Starter' | 'Growth' | 'Scale';
status: 'Active' | 'Trial' | 'Past due';
};
const customers: Customer[] = [
{ id: 'c1', name: 'Aria Patel', email: 'aria@acme.io', plan: 'Growth', status: 'Active' },
{ id: 'c2', name: 'Diego Ramos', email: 'diego@acme.io', plan: 'Scale', status: 'Active' },
{ id: 'c3', name: 'Mei Lin', email: 'mei@acme.io', plan: 'Starter', status: 'Trial' },
{ id: 'c4', name: 'Noah Becker', email: 'noah@acme.io', plan: 'Growth', status: 'Past due' },
{ id: 'c5', name: 'Priya Nair', email: 'priya@acme.io', plan: 'Scale', status: 'Active' },
{ id: 'c6', name: 'Lucas Ferreira', email: 'lucas@acme.io', plan: 'Starter', status: 'Trial' },
{ id: 'c7', name: 'Hana Sato', email: 'hana@acme.io', plan: 'Growth', status: 'Active' },
{ id: 'c8', name: 'Owen Clarke', email: 'owen@acme.io', plan: 'Starter', status: 'Active' },
{ id: 'c9', name: 'Freya Larsen', email: 'freya@acme.io', plan: 'Scale', status: 'Past due' },
{ id: 'c10', name: 'Malik Johnson', email: 'malik@acme.io', plan: 'Growth', status: 'Active' },
{ id: 'c11', name: 'Ines Duarte', email: 'ines@acme.io', plan: 'Starter', status: 'Trial' },
{ id: 'c12', name: 'Sana Malik', email: 'sana@acme.io', plan: 'Scale', status: 'Active' },
];
const statusIntent: Record<Customer['status'], string> = {
Active: 'bg-success',
Trial: 'bg-info',
'Past due': 'bg-destructive',
};
const columns: ColumnDef<Customer>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email', enableSorting: false },
{
accessorKey: 'plan',
header: 'Plan',
enableSorting: false,
cell: ({ row }) => <Tag className="bg-card">{row.original.plan}</Tag>,
},
{
accessorKey: 'status',
header: 'Status',
enableSorting: false,
cell: ({ row }) => (
<Tag
prefix
prefixClass={statusIntent[row.original.status]}
className="bg-card"
>
{row.original.status}
</Tag>
),
},
];
export default function UsageDemo() {
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sort, setSort] = useState<OnSortParam>({ sortOrder: '', sortKey: '' });
const sorted = useMemo(() => {
if (!sort.sortKey) return customers;
const key = sort.sortKey as keyof Customer;
return [...customers].sort((a, b) => {
const diff = String(a[key]).localeCompare(String(b[key]));
return sort.sortOrder === 'desc' ? -diff : diff;
});
}, [sort]);
const start = (pageIndex - 1) * pageSize;
const pageRows = sorted.slice(start, start + pageSize);
return (
<DataTable<Customer>
overflowClass="border rounded-card"
columns={columns}
data={pageRows}
pagingData={{ total: customers.length, pageIndex, pageSize }}
onPaginationChange={setPageIndex}
onPageSizeChange={(size) => {
setPageSize(size);
setPageIndex(1);
}}
onSort={(nextSort) => {
setSort(nextSort);
setPageIndex(1);
}}
/>
);
}Installation
Add this component with the NateUI CLI.
npx nateui@latest add DataTableExamples
Sorting
onSort receives the clicked column's key and direction; DataTable expects
the parent to sort data before passing it back in.
Deal | Stage | Value | Close date |
|---|---|---|---|
| Fenwick Labs | Negotiation | $48,000 | 2026-07-18 |
| Bright Harbor Co | Proposal | $22,500 | 2026-07-25 |
| Northgate Retail | Discovery | $9,800 | 2026-08-02 |
| Alderwood Group | Closed Won | $63,000 | 2026-06-30 |
| Pinecrest Studio | Negotiation | $31,500 | 2026-07-21 |
| Solace Health | Proposal | $17,200 | 2026-08-09 |
- 1
import { useMemo, useState } from 'react';
import DataTable from '@/components/composites/DataTable';
import Tag from '@/components/ui/Tag';
import type { ColumnDef, OnSortParam } from '@/components/composites/DataTable';
type Deal = {
id: string;
name: string;
stage: 'Discovery' | 'Proposal' | 'Negotiation' | 'Closed Won';
value: number;
closeDate: string;
};
const deals: Deal[] = [
{ id: 'd1', name: 'Fenwick Labs', stage: 'Negotiation', value: 48000, closeDate: '2026-07-18' },
{ id: 'd2', name: 'Bright Harbor Co', stage: 'Proposal', value: 22500, closeDate: '2026-07-25' },
{ id: 'd3', name: 'Northgate Retail', stage: 'Discovery', value: 9800, closeDate: '2026-08-02' },
{ id: 'd4', name: 'Alderwood Group', stage: 'Closed Won', value: 63000, closeDate: '2026-06-30' },
{ id: 'd5', name: 'Pinecrest Studio', stage: 'Negotiation', value: 31500, closeDate: '2026-07-21' },
{ id: 'd6', name: 'Solace Health', stage: 'Proposal', value: 17200, closeDate: '2026-08-09' },
];
const stageIntent: Record<Deal['stage'], string | undefined> = {
Discovery: undefined,
Proposal: 'bg-info',
Negotiation: 'bg-warning',
'Closed Won': 'bg-success',
};
const columns: ColumnDef<Deal>[] = [
{ accessorKey: 'name', header: 'Deal' },
{
accessorKey: 'stage',
header: 'Stage',
enableSorting: false,
cell: ({ row }) => (
<Tag prefix prefixClass={stageIntent[row.original.stage]} className="bg-card">
{row.original.stage}
</Tag>
),
},
{
accessorKey: 'value',
header: 'Value',
cell: ({ row }) => `$${row.original.value.toLocaleString()}`,
},
{ accessorKey: 'closeDate', header: 'Close date' },
];
export default function SortingDemo() {
const [sort, setSort] = useState<OnSortParam>({ sortOrder: '', sortKey: '' });
const sorted = useMemo(() => {
if (!sort.sortKey) return deals;
const key = sort.sortKey as keyof Deal;
return [...deals].sort((a, b) => {
const diff =
typeof a[key] === 'number' && typeof b[key] === 'number'
? a[key] - b[key]
: String(a[key]).localeCompare(String(b[key]));
return sort.sortOrder === 'desc' ? -diff : diff;
});
}, [sort]);
return (
<DataTable<Deal>
overflowClass="border rounded-card"
columns={columns}
data={sorted}
pagingData={{ total: deals.length, pageIndex: 1, pageSize: 10 }}
onSort={setSort}
/>
);
}Row Selection
selectable adds a checkbox column; onRowSelect and onAllRowSelect report
which rows are checked so a parent can drive bulk actions.
Ticket | Subject | Priority | Assignee | |
|---|---|---|---|---|
| TCK-201 | Checkout button unresponsive | High | Aria Patel | |
| TCK-198 | Export CSV missing a column | Medium | Diego Ramos | |
| TCK-195 | Typo in confirmation email | Low | Mei Lin | |
| TCK-190 | Slow load on reports page | Medium | Aria Patel | |
| TCK-187 | Dark mode toggle resets on reload | Medium | Owen Clarke |
- 1
import { useState } from 'react';
import DataTable from '@/components/composites/DataTable';
import Button from '@/components/ui/Button';
import Tag from '@/components/ui/Tag';
import type { ColumnDef, Row } from '@/components/composites/DataTable';
type Ticket = {
id: string;
subject: string;
priority: 'Low' | 'Medium' | 'High';
assignee: string;
};
const initialTickets: Ticket[] = [
{ id: 'TCK-201', subject: 'Checkout button unresponsive', priority: 'High', assignee: 'Aria Patel' },
{ id: 'TCK-198', subject: 'Export CSV missing a column', priority: 'Medium', assignee: 'Diego Ramos' },
{ id: 'TCK-195', subject: 'Typo in confirmation email', priority: 'Low', assignee: 'Mei Lin' },
{ id: 'TCK-190', subject: 'Slow load on reports page', priority: 'Medium', assignee: 'Aria Patel' },
{ id: 'TCK-187', subject: 'Dark mode toggle resets on reload', priority: 'Medium', assignee: 'Owen Clarke' },
];
const columns: ColumnDef<Ticket>[] = [
{ accessorKey: 'id', header: 'Ticket' },
{ accessorKey: 'subject', header: 'Subject' },
{
accessorKey: 'priority',
header: 'Priority',
cell: ({ row }) => (
<Tag
prefix
prefixClass={
row.original.priority === 'High'
? 'bg-destructive'
: row.original.priority === 'Medium'
? 'bg-warning'
: undefined
}
className="bg-card"
>
{row.original.priority}
</Tag>
),
},
{ accessorKey: 'assignee', header: 'Assignee' },
];
export default function RowSelectionDemo() {
const [tickets, setTickets] = useState(initialTickets);
const [selected, setSelected] = useState<Ticket[]>([]);
const handleRowSelect = (checked: boolean, row: Ticket) => {
setSelected((prev) =>
checked ? [...prev, row] : prev.filter((ticket) => ticket.id !== row.id),
);
};
const handleAllRowSelect = (checked: boolean, rows: Row<Ticket>[]) => {
setSelected(checked ? rows.map((row) => row.original) : []);
};
const handleArchive = () => {
const archivedIds = new Set(selected.map((ticket) => ticket.id));
setTickets((prev) => prev.filter((ticket) => !archivedIds.has(ticket.id)));
setSelected([]);
};
return (
<div className="flex flex-col gap-2 w-full">
{selected.length > 0 && (
<div className="flex items-center justify-between gap-4 rounded-card border bg-card px-4 py-2">
<span className="text-sm text-muted-foreground">
{selected.length} ticket{selected.length > 1 ? 's' : ''} selected
</span>
<Button size="sm" variant="solid" onClick={handleArchive}>
Archive selected
</Button>
</div>
)}
<DataTable<Ticket>
selectable
overflowClass="border rounded-card"
columns={columns}
data={tickets}
pagingData={{ total: tickets.length, pageIndex: 1, pageSize: 10 }}
onRowSelect={handleRowSelect}
onAllRowSelect={handleAllRowSelect}
/>
</div>
);
}Column Resizing
columnResize adds resize handles to DataTable's default header cells.
Column size, minSize, and maxSize still come from the column definitions.
SKU | Product | Category | Price | Stock |
|---|---|---|---|---|
| WM-1001 | Wireless Mouse | Peripherals | $29.00 | 184 |
| MK-2044 | Mechanical Keyboard | Peripherals | $89.00 | 62 |
| UM-3320 | USB-C Monitor Arm | Accessories | $54.00 | 41 |
| DK-4108 | Standing Desk Converter | Furniture | $210.00 | 15 |
| HP-5061 | Noise-Cancelling Headset | Audio | $135.00 | 73 |
- 1
import DataTable from '@/components/composites/DataTable';
import type { ColumnDef } from '@/components/composites/DataTable';
type Product = {
id: string;
sku: string;
name: string;
category: string;
price: string;
stock: number;
};
const products: Product[] = [
{ id: 'p1', sku: 'WM-1001', name: 'Wireless Mouse', category: 'Peripherals', price: '$29.00', stock: 184 },
{ id: 'p2', sku: 'MK-2044', name: 'Mechanical Keyboard', category: 'Peripherals', price: '$89.00', stock: 62 },
{ id: 'p3', sku: 'UM-3320', name: 'USB-C Monitor Arm', category: 'Accessories', price: '$54.00', stock: 41 },
{ id: 'p4', sku: 'DK-4108', name: 'Standing Desk Converter', category: 'Furniture', price: '$210.00', stock: 15 },
{ id: 'p5', sku: 'HP-5061', name: 'Noise-Cancelling Headset', category: 'Audio', price: '$135.00', stock: 73 },
];
const columns: ColumnDef<Product>[] = [
{ accessorKey: 'sku', header: 'SKU', size: 110, minSize: 90 },
{ accessorKey: 'name', header: 'Product', size: 220, minSize: 180 },
{ accessorKey: 'category', header: 'Category', size: 140, minSize: 120 },
{ accessorKey: 'price', header: 'Price', size: 100, minSize: 90 },
{ accessorKey: 'stock', header: 'Stock', size: 90, minSize: 80 },
];
export default function ColumnResizingDemo() {
return (
<DataTable<Product>
overflowClass="border rounded-card"
columns={columns}
data={products}
columnResize={{ mode: 'onChange' }}
pagingData={{ total: products.length, pageIndex: 1, pageSize: 10 }}
/>
);
}Pinned Columns
columnPinning sticks columns in DataTable's default renderer. Pin by column
id, then use column size values to keep the start and end offsets stable.
When selectable is also enabled, DataTable pins its checkbox first at the
logical start, before consumer-declared start-pinned columns.
Name | Email | Department | Location | Manager | Start date | |
|---|---|---|---|---|---|---|
| Aria Patel | aria@acme.io | Engineering | Remote | Noah Becker | 2023-04-11 | |
| Diego Ramos | diego@acme.io | Design | Austin, TX | Mei Lin | 2022-09-02 | |
| Priya Nair | priya@acme.io | Support | Remote | Owen Clarke | 2024-01-19 | |
| Lucas Ferreira | lucas@acme.io | Sales | New York, NY | Freya Larsen | 2023-11-06 | |
| Hana Sato | hana@acme.io | Engineering | Remote | Noah Becker | 2021-06-24 |
- 1
import DataTable from '@/components/composites/DataTable';
import Button from '@/components/ui/Button';
import type { ColumnDef } from '@/components/composites/DataTable';
type Employee = {
id: string;
name: string;
email: string;
department: string;
location: string;
manager: string;
startDate: string;
};
const employees: Employee[] = [
{ id: 'e1', name: 'Aria Patel', email: 'aria@acme.io', department: 'Engineering', location: 'Remote', manager: 'Noah Becker', startDate: '2023-04-11' },
{ id: 'e2', name: 'Diego Ramos', email: 'diego@acme.io', department: 'Design', location: 'Austin, TX', manager: 'Mei Lin', startDate: '2022-09-02' },
{ id: 'e3', name: 'Priya Nair', email: 'priya@acme.io', department: 'Support', location: 'Remote', manager: 'Owen Clarke', startDate: '2024-01-19' },
{ id: 'e4', name: 'Lucas Ferreira', email: 'lucas@acme.io', department: 'Sales', location: 'New York, NY', manager: 'Freya Larsen', startDate: '2023-11-06' },
{ id: 'e5', name: 'Hana Sato', email: 'hana@acme.io', department: 'Engineering', location: 'Remote', manager: 'Noah Becker', startDate: '2021-06-24' },
];
const columns: ColumnDef<Employee>[] = [
{ accessorKey: 'name', header: 'Name', size: 160 },
{ accessorKey: 'email', header: 'Email', size: 200 },
{ accessorKey: 'department', header: 'Department', size: 150 },
{ accessorKey: 'location', header: 'Location', size: 150 },
{ accessorKey: 'manager', header: 'Manager', size: 150 },
{ accessorKey: 'startDate', header: 'Start date', size: 130 },
{
id: 'actions',
header: '',
size: 90,
cell: () => (
<Button size="sm">
View
</Button>
),
},
];
export default function PinnedColumnsDemo() {
return (
<DataTable<Employee>
overflowClass="border rounded-card"
columns={columns}
data={employees}
columnPinning={{ start: ['name'], end: ['actions'] }}
pagingData={{ total: employees.length, pageIndex: 1, pageSize: 10 }}
/>
);
}Expandable Rows
Rows carry their own depth and parentId; collapsing a folder filters its
descendants out of the visible list, so this uses DataTable's default renderer
directly.
Name | Type | Size | Modified |
|---|---|---|---|
Brand Kit | Folder | 2 items | Jun 2, 2026 |
Logo Variants | Folder | 2 items | May 18, 2026 |
Primary Logo.png | Image | 860 KB | May 12, 2026 |
Logo Guidelines.pdf | Document | 1.4 MB | May 18, 2026 |
Brand Voice.md | Text | 9 KB | Jun 2, 2026 |
Product Screenshots | Folder | 2 items | Jun 20, 2026 |
dashboard-dark.png | Image | 2.1 MB | Jun 14, 2026 |
dashboard-light.png | Image | 2.3 MB | Jun 20, 2026 |
changelog.txt | Text | 6 KB | Jun 28, 2026 |
- 1
import { useMemo, useState } from 'react';
import DataTable from '@/components/composites/DataTable';
import Tag from '@/components/ui/Tag';
import {
PiCaretRight,
PiFile,
PiFolder,
PiFolderOpen,
PiImage,
} from 'react-icons/pi'
import type { ColumnDef } from '@/components/composites/DataTable';
type FileType = 'folder' | 'document' | 'text' | 'image';
type FileNode = {
id: string;
name: string;
type: FileType;
size: string;
modified: string;
depth: number;
parentId: string | null;
};
const nodes: FileNode[] = [
{ id: 'brand-kit', name: 'Brand Kit', type: 'folder', size: '2 items', modified: 'Jun 2, 2026', depth: 0, parentId: null },
{ id: 'logo-variants', name: 'Logo Variants', type: 'folder', size: '2 items', modified: 'May 18, 2026', depth: 1, parentId: 'brand-kit' },
{ id: 'primary-logo', name: 'Primary Logo.png', type: 'image', size: '860 KB', modified: 'May 12, 2026', depth: 2, parentId: 'logo-variants' },
{ id: 'logo-guidelines', name: 'Logo Guidelines.pdf', type: 'document', size: '1.4 MB', modified: 'May 18, 2026', depth: 2, parentId: 'logo-variants' },
{ id: 'brand-voice', name: 'Brand Voice.md', type: 'text', size: '9 KB', modified: 'Jun 2, 2026', depth: 1, parentId: 'brand-kit' },
{ id: 'product-screenshots', name: 'Product Screenshots', type: 'folder', size: '2 items', modified: 'Jun 20, 2026', depth: 0, parentId: null },
{ id: 'dashboard-dark', name: 'dashboard-dark.png', type: 'image', size: '2.1 MB', modified: 'Jun 14, 2026', depth: 1, parentId: 'product-screenshots' },
{ id: 'dashboard-light', name: 'dashboard-light.png', type: 'image', size: '2.3 MB', modified: 'Jun 20, 2026', depth: 1, parentId: 'product-screenshots' },
{ id: 'changelog', name: 'changelog.txt', type: 'text', size: '6 KB', modified: 'Jun 28, 2026', depth: 0, parentId: null },
];
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const typeStyles: Record<FileType, { tagClassName: string; label: string }> = {
folder: {
tagClassName: 'text-palette-yellow-soft-foreground bg-palette-yellow-soft',
label: 'Folder'
},
document: {
tagClassName: 'text-palette-blue-soft-foreground bg-palette-blue-soft',
label: 'Document'
},
text: {
tagClassName: 'text-palette-cyan-soft-foreground bg-palette-cyan-soft',
label: 'Text'
},
image: {
tagClassName: 'text-palette-purple-soft-foreground bg-palette-purple-soft',
label: 'Image'
},
};
function isVisible(node: FileNode, expanded: Set<string>): boolean {
let current = node;
while (current.parentId) {
const parent = nodesById.get(current.parentId);
if (!parent || !expanded.has(current.parentId)) return false;
current = parent;
}
return true;
}
export default function ExpandableRowsDemo() {
const [expanded, setExpanded] = useState<Set<string>>(
new Set(['brand-kit', 'logo-variants', 'product-screenshots']),
);
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const visibleNodes = useMemo(
() => nodes.filter((node) => isVisible(node, expanded)),
[expanded],
);
const columns: ColumnDef<FileNode>[] = useMemo(
() => [
{
accessorKey: 'name',
header: 'Name',
enableSorting: false,
size: 280,
cell: ({ row }) => {
const node = row.original;
const isFolder = node.type === 'folder';
const isOpen = expanded.has(node.id);
return (
<div className="flex items-center gap-2" style={{ paddingLeft: node.depth * 20 }}>
{isFolder ? (
<button
type="button"
aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
className="inline-flex cursor-pointer text-muted-foreground"
onClick={() => toggle(node.id)}
>
<PiCaretRight className={isOpen ? 'rotate-90 transition' : 'transition'} />
</button>
) : (
<span className="inline-block w-4" />
)}
{isFolder ? (
isOpen ? (
<PiFolderOpen />
) : (
<PiFolder />
)
) : node.type === 'image' ? (
<PiImage />
) : (
<PiFile />
)}
<span className={isFolder ? 'font-medium text-foreground' : 'text-foreground'}>
{node.name}
</span>
</div>
);
},
},
{
accessorKey: 'type',
header: 'Type',
enableSorting: false,
size: 120,
cell: ({ row }) => {
const style = typeStyles[row.original.type];
return (
<Tag
className={`${style.tagClassName} border-0`}
>
{style.label}
</Tag>
);
},
},
{ accessorKey: 'size', header: 'Size', enableSorting: false, size: 100 },
{ accessorKey: 'modified', header: 'Modified', enableSorting: false, size: 130 },
],
[expanded],
);
return (
<DataTable<FileNode>
overflowClass="border rounded-card"
columns={columns}
data={visibleNodes}
pagingData={{ total: visibleNodes.length, pageIndex: 1, pageSize: 10 }}
/>
);
}Drag And Drop
Reordering rows needs the underlying TanStack table instance plus
@dnd-kit. This example renders through DataTable's
children render prop and uses Table's asElement escape hatch so sortable
rows move as grid-backed blocks instead of native table rows.
- 1
import { useMemo, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import DataTable from '@/components/composites/DataTable';
import Table from '@/components/ui/Table';
import Tag from '@/components/ui/Tag';
import { flexRender } from '@tanstack/react-table';
import {
DndContext,
closestCenter,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { PiDotsSixVertical } from 'react-icons/pi'
import type { ColumnDef } from '@/components/composites/DataTable';
const { THead, TBody, Tr, Th, Td } = Table;
type Feature = {
id: string;
title: string;
votes: number;
status: 'Planned' | 'In progress';
};
const initialFeatures: Feature[] = [
{ id: 'f1', title: 'Bulk CSV export', votes: 128, status: 'In progress' },
{ id: 'f2', title: 'Saved filter views', votes: 96, status: 'Planned' },
{ id: 'f3', title: 'Slack notifications', votes: 74, status: 'Planned' },
{ id: 'f4', title: 'Dark mode scheduling', votes: 51, status: 'Planned' },
{ id: 'f5', title: 'Audit log retention', votes: 33, status: 'Planned' },
];
const columns: ColumnDef<Feature>[] = [
{ id: 'drag', header: '', size: 32 },
{ accessorKey: 'title', header: 'Feature request' },
{ accessorKey: 'votes', header: 'Votes' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Tag
prefix
prefixClass={row.original.status === 'In progress' ? 'bg-info' : 'bg-muted'}
className="bg-card"
>
{row.original.status}
</Tag>
),
},
];
const rowGrid = 'grid grid-cols-[56px_minmax(220px,1fr)_110px_150px] items-center';
type SortableRenderProps = Pick<
ReturnType<typeof useSortable>,
'attributes' | 'listeners' | 'setActivatorNodeRef' | 'isDragging'
>;
function SortableRow({
id,
label,
children,
}: {
id: string;
label: string;
children: (props: SortableRenderProps) => ReactNode;
}) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: CSSProperties = {
transform: CSS.Translate.toString(transform),
transition,
position: 'relative',
zIndex: isDragging ? 1 : undefined,
};
return (
<Tr
asElement="div"
ref={setNodeRef}
style={style}
aria-label={label}
className={
isDragging
? `${rowGrid} bg-card shadow-lg ring-1 ring-primary/30`
: rowGrid
}
>
{children({ attributes, listeners, setActivatorNodeRef, isDragging })}
</Tr>
);
}
export default function DragAndDropDemo() {
const [features, setFeatures] = useState(initialFeatures);
const featureIds = useMemo(
() => features.map((feature) => feature.id),
[features],
);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setFeatures((prev) => {
const oldIndex = prev.findIndex((item) => item.id === active.id);
const newIndex = prev.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
};
return (
<DataTable<Feature>
columns={columns}
data={features}
pagingData={{ total: features.length, pageIndex: 1, pageSize: 10 }}
>
{({ table }) => (
<DndContext
id="data-table-drag-and-drop"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table
overflowClass="border rounded-card"
asElement="div"
className="divide-x-0"
>
<THead asElement="div">
{table.getHeaderGroups().map((headerGroup) => (
<Tr key={headerGroup.id} asElement="div" className={rowGrid}>
{headerGroup.headers.map((header) => (
<Th key={header.id} asElement="div">
{flexRender(header.column.columnDef.header, header.getContext())}
</Th>
))}
</Tr>
))}
</THead>
<SortableContext
items={featureIds}
strategy={verticalListSortingStrategy}
>
<TBody asElement="div">
{table.getRowModel().rows.map((row) => (
<SortableRow
key={row.original.id}
id={row.original.id}
label={`Reorder ${row.original.title}`}
>
{({ attributes, listeners, setActivatorNodeRef }) =>
row.getVisibleCells().map((cell) => {
if (cell.column.id === 'drag') {
return (
<Td
key={cell.id}
asElement="div"
className="flex justify-center"
>
<button
ref={setActivatorNodeRef}
type="button"
aria-label={`Reorder ${row.original.title}`}
className="inline-flex cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<PiDotsSixVertical aria-hidden />
</button>
</Td>
);
}
return (
<Td key={cell.id} asElement="div">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
);
})
}
</SortableRow>
))}
</TBody>
</SortableContext>
</Table>
</DndContext>
)}
</DataTable>
);
}Empty State
Set noData when the current result set has no rows. Use renderEmptyState to
replace the built-in state with a complete EmptyState composition, including
a custom illustration, supporting copy, and an action.
Name | Email |
|---|---|
No customers foundTry adjusting your search or filters. | |
import EmptyState from '@/components/composites/EmptyState';
import DataTable from '@/components/composites/DataTable';
import Button from '@/components/ui/Button';
import type { ColumnDef } from '@/components/composites/DataTable';
type Customer = {
id: string;
name: string;
email: string;
};
const columns: ColumnDef<Customer>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
];
const frontPanelGradientId = 'data-table-empty-state-front-panel-gradient';
const SearchResultsIllustration = () => (
<svg
width="124"
height="86"
viewBox="0 0 124 86"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient
id={frontPanelGradientId}
x1="59.7644"
y1="8.3401"
x2="59.7644"
y2="69.1341"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="var(--nui-card)" />
<stop offset="0.9964" stopColor="var(--nui-muted)" />
</linearGradient>
</defs>
<path
className="fill-muted-foreground/40"
d="M21.4994 0H93.5994C96.4994 0 98.6994 2.2 98.6994 5.1V50.8C98.6994 53.7 96.4994 55.9 93.5994 55.9H21.4994C18.5994 55.9 16.3994 53.7 16.3994 50.8V5.1C16.3994 2.2 18.7994 0 21.4994 0Z"
/>
<path
className="fill-card"
d="M93.7996 16.7002H70.3996C68.0996 16.7002 65.8996 17.5002 64.1996 18.9002L58.0996 23.9002C56.3996 25.3002 54.1996 26.1002 51.8996 26.1002H25.4994C22.7994 26.1002 20.5996 28.3002 20.5996 31.0002C20.5996 31.2002 20.5996 31.5002 20.6996 31.7002L25.5996 57.5002C25.9996 59.9002 27.9996 61.7002 30.4996 61.7002H86.9996C89.3996 61.7002 91.4996 60.0002 91.8996 57.6002L98.6996 22.3002C99.1996 19.6002 97.3994 17.2002 94.6994 16.7002C94.2994 16.7002 93.9994 16.7002 93.7996 16.7002Z"
/>
<path
className="stroke-border"
fill={`url(#${frontPanelGradientId})`}
strokeWidth="1"
d="M104.3 9.80019H73.9C70.9 9.80019 68.1 10.8002 65.8 12.7002L57.8 19.2002C55.6 21.0002 52.7 22.1002 49.7 22.1002H15.3C11.8 22.1002 9 25.0002 9 28.4002C9 28.7002 9 29.0002 9.1 29.3002L15.4 63.0002C15.9 66.2002 18.6 68.5002 21.7 68.5002H95.3C98.5 68.5002 101.1 66.3002 101.6 63.1002L110.5 17.0002C111.1 13.5002 108.8 10.4002 105.3 9.7002C105 9.8002 104.6 9.80019 104.3 9.80019Z"
/>
<path
className="fill-muted-foreground"
d="M45.4992 44.7001C47.2992 44.7001 48.7992 43.2001 48.7992 41.4001C48.7992 39.6001 47.2992 38.1001 45.4992 38.1001C43.6992 38.1001 42.1992 39.6001 42.1992 41.4001C42.1992 43.2001 43.6992 44.7001 45.4992 44.7001Z"
/>
<path
className="fill-muted-foreground"
d="M71.1994 44.6C72.9994 44.6 74.4994 43.1 74.4994 41.3C74.4994 39.5 72.9994 38 71.1994 38C69.3994 38 67.8994 39.5 67.8994 41.3C67.8994 43.2 69.3994 44.6 71.1994 44.6Z"
/>
<path
className="fill-muted-foreground"
d="M61.4998 48.2002H55.2998V49.7002H61.4998V48.2002Z"
/>
<path
className="fill-muted"
d="M95.57 73.314C104.609 73.314 111.937 66.4184 111.937 57.8823C111.937 49.3463 104.577 42.4507 95.57 42.4507C86.5311 42.4507 79.2031 49.3463 79.2031 57.8823C79.2031 66.4184 86.5311 73.314 95.57 73.314Z"
/>
<path
className="fill-muted-foreground/70"
d="M117.208 81.467L107.073 71.3404L109.397 69.0171L119.523 79.1517L117.208 81.467Z"
/>
<path
className="fill-muted-foreground/70"
d="M120.731 85.0303L112.959 77.2591C112.31 76.6102 112.31 75.5526 112.959 74.9037C113.608 74.2548 114.666 74.2548 115.315 74.9037L123.086 82.6749C123.735 83.3238 123.735 84.3813 123.086 85.0303C122.437 85.6792 121.379 85.6792 120.731 85.0303Z"
/>
<path
className="fill-muted-foreground/70"
d="M120.179 85.5268L110.18 75.5284C109.948 75.296 109.948 74.9195 110.18 74.6791L112.736 72.1235C112.968 71.8911 113.345 71.8911 113.585 72.1235L123.583 82.1219C123.816 82.3542 123.816 82.7307 123.583 82.9711L121.028 85.5268C120.787 85.7591 120.411 85.7591 120.179 85.5268Z"
/>
<path
className="fill-muted-foreground"
d="M95.6011 38.7095C84.9938 38.7095 76.3975 47.3059 76.3975 57.9131C76.3975 68.5124 84.9938 77.1168 95.5931 77.1168C106.2 77.1168 114.797 68.5204 114.797 57.9131C114.797 47.3059 106.2 38.7095 95.6011 38.7095ZM95.6011 73.5597C87.0848 73.5597 80.1869 66.5496 80.1869 57.9051C80.1869 49.2607 87.0848 42.2586 95.6011 42.2586C104.117 42.2586 111.015 49.2687 111.015 57.9131C111.015 66.5576 104.109 73.5597 95.6011 73.5597Z"
/>
<path
className="fill-muted-foreground"
d="M93.9808 61.0024C93.9452 60.7186 93.9097 60.4347 93.9097 60.1154C93.9097 58.9446 94.4074 57.8802 95.6516 56.9577L96.7181 56.1771C97.3936 55.6804 97.678 55.1127 97.678 54.4386C97.678 53.4451 96.967 52.4872 95.4739 52.4872C93.9097 52.4872 93.2342 53.729 93.2342 54.9708C93.2342 55.2192 93.2698 55.4675 93.3053 55.6804L90.5324 55.574C90.4613 55.2546 90.4258 54.8998 90.4258 54.5805C90.4258 52.2033 92.2033 49.9326 95.4383 49.9326C98.8511 49.9326 100.629 52.0614 100.629 54.2967C100.629 56.0352 99.7399 57.2415 98.4601 58.164L97.5713 58.8026C96.7892 59.3348 96.3982 60.0444 96.3982 60.9315V61.0734H93.9808V61.0024ZM95.225 62.4216C96.1849 62.4216 96.967 63.2022 96.967 64.1601C96.967 65.1181 96.1849 65.8987 95.225 65.8987C94.2652 65.8987 93.4831 65.1181 93.4831 64.1601C93.4831 63.2022 94.2652 62.4216 95.225 62.4216Z"
/>
</svg>
);
export default function EmptyStateDemo() {
return (
<DataTable<Customer>
overflowClass="border rounded-card"
columns={columns}
hoverable={false}
data={[]}
noData
pagingData={{ total: 0, pageIndex: 1, pageSize: 10 }}
renderEmptyState={
<div
aria-labelledby="data-table-empty-state-title"
className="flex w-full items-center justify-center px-4 py-8 sm:px-8 sm:py-12"
>
<div className="w-full max-w-md text-center">
<EmptyState
size={280}
illustration={
<div className="relative isolate">
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-2 z-0"
>
<span className="absolute inset-0 rounded-full bg-card/70 blur-xl" />
<span className="absolute inset-2 rounded-full bg-card" />
</div>
<div className="relative z-1">
<SearchResultsIllustration />
</div>
</div>
}
offset={-60}
variant="dots"
>
<div>
<div className="mb-4">
<h4
id="data-table-empty-state-title"
className="text-lg font-semibold text-foreground"
>
No customers found
</h4>
<p className="mx-auto max-w-sm text-muted-foreground">
Try adjusting your search or filters.
</p>
</div>
<Button type="button">Clear filters</Button>
</div>
</EmptyState>
</div>
</div>
}
/>
);
}API
DataTable wraps TanStack Table - columns,
Row<T>, and the render prop's table instance all come directly from that
library; see its own docs for the full column-def and table-instance API.
Sorting and pagination stay manual: DataTable expects data to already be
sorted and sliced to the current page, then calls back through onSort /
onPaginationChange / onPageSizeChange so a parent can re-sort or re-fetch.
Column resizing and sticky column pinning are built into the default renderer
when columnResize or columnPinning is enabled. Pass children only when
you need to replace the table structure entirely, as the Drag And Drop example
does.
| Prop | Description | Type | Default |
|---|---|---|---|
columns | Column definitions for the table. | ColumnDef<T>[] | [] |
data | Rows to render for the current page. | T[] | [] |
loading | Shows skeleton rows while empty, or dims the table when rows are already present. | boolean | false |
noData | Renders the built-in empty state instead of rows. | boolean | false |
renderEmptyState | Replaces the default empty-state content. | ReactNode | - |
selectable | Adds a checkbox column for row and select-all selection. | boolean | false |
onRowSelect | Called when a single row's checkbox changes. | (checked: boolean, row: T) => void | - |
onAllRowSelect | Called when the header select-all checkbox changes. | (checked: boolean, rows: Row<T>[]) => void | - |
checkboxChecked | Overrides a row's checked state instead of TanStack's internal selection. | (row: T) => boolean | - |
indeterminateCheckboxChecked | Overrides the header checkbox's checked state. | (rows: Row<T>[]) => boolean | - |
onSort | Called when the sorted column or direction changes. | (sort: OnSortParam) => void | - |
columnPinning | Pins columns at the logical start or end by column id and renders them sticky in the default table. | ColumnPinningState | - |
columnResize | Adds resize handles to header cells. Pass { mode: 'onChange' } for live resizing while dragging. | boolean | ColumnResizeConfig | false |
pagingData | Current page, page size, and total row count. | { total: number; pageIndex: number; pageSize: number } | { total: 0, pageIndex: 1, pageSize: 10 } |
onPaginationChange | Called when the page changes. | (page: number) => void | - |
pageSizes | Options shown in the page-size select. | number[] | [10, 25, 50, 100] |
onPageSizeChange | Called when the page-size select changes. | (size: number) => void | - |
skeletonAvatarColumns | Column indexes that render an avatar-shaped skeleton while loading. | number[] | - |
skeletonAvatarProps | Props for those skeleton avatars. | SkeletonProps | - |
children | Renders the table body yourself from the underlying TanStack instance, instead of DataTable's default rows. | (props: { table: Table<T> }) => ReactNode | - |
ref | Exposes resetSorting() and resetSelected(). | Ref<DataTableResetHandle> | - |
...tableProps | Forwarded to the underlying Table, for example bordered, compact, or overflow. | TableProps | - |
Types
type OnSortParam = { sortOrder: 'asc' | 'desc' | ''; sortKey: string | number };
type ColumnPinningState = { start: string[]; end: string[] };
type ColumnResizeConfig = { mode?: ColumnResizeMode };
type ColumnResizeMode = 'onChange' | 'onEnd';
type DataTableResetHandle = { resetSorting: () => void; resetSelected: () => void };