Data Table
Typed, zero-dependency data grid that auto-switches between a hand-rolled virtualized engine (10 to 100,000 rows) and a standard engine with row grouping and rowspan. Search, single and multi sort, multi-select, row expansion, frozen columns, column groups, pinned rows, and feature presets.
UniqueUI CLI
npx uniqueui add data-table
shadcn CLI
npx shadcn@latest add https://uniqueui-platform.vercel.app/r/data-table.json -y
shadcn path expects @/lib/utils (run shadcn init first). Same source file is installed to components/ui/.
Basic preset
Variant 1 of 5| Alex Kim | Engineer | Platform | Active | 2023-01-09 |
| Sara Chen | Designer | Product | Active | 2023-03-14 |
| Jordan Lee | PM | Growth | Away | 2023-06-02 |
| Maya Patel | Engineer | Platform | Active | 2024-01-22 |
| Ryan Wu | Designer | Product | Away | 2024-02-11 |
| Priya Shah | PM | Growth | Active | 2024-04-30 |
"use client";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
interface Member {
id: string;
name: string;
role: string;
department: string;
joined: Date;
}
const data: Member[] = [
{ id: "1", name: "Alex Kim", role: "Engineer", department: "Platform", joined: new Date("2023-01-09") },
{ id: "2", name: "Sara Chen", role: "Designer", department: "Product", joined: new Date("2023-03-14") },
{ id: "3", name: "Jordan Lee", role: "PM", department: "Growth", joined: new Date("2023-06-02") },
{ id: "4", name: "Maya Patel", role: "Engineer", department: "Platform", joined: new Date("2024-01-22") },
{ id: "5", name: "Ryan Wu", role: "Designer", department: "Product", joined: new Date("2024-02-11") },
{ id: "6", name: "Priya Shah", role: "PM", department: "Growth", joined: new Date("2024-04-30") },
{ id: "7", name: "Sam Rivera", role: "Engineer", department: "Platform", joined: new Date("2024-05-18") },
];
const columns: DataTableColumn<Member>[] = [
{ id: "name", header: "Name", accessor: (row) => row.name },
{ id: "role", header: "Role", accessor: (row) => row.role },
{ id: "department", header: "Department", accessor: (row) => row.department },
{ id: "joined", header: "Joined", accessor: (row) => row.joined, cell: (row) => row.joined.toISOString().slice(0, 10) },
];
export default function Example() {
return (
<div className="w-full p-6">
<DataTable data={data} columns={columns} getRowId={(row) => row.id} preset="basic" pageSize={5} theme="dark" />
</div>
);
}Advanced preset
Variant 2 of 5| #2400 | Alex Kim | $24.00 | Paid | 2026-01-01 | ||
| #2401 | Sara Chen | $77.70 | Pending | 2026-02-02 | ||
| #2402 | Jordan Lee | $131.40 | Refunded | 2026-03-03 | ||
| #2403 | Maya Patel | $185.10 | Paid | 2026-04-04 | ||
| #2404 | Ryan Wu | $238.80 | Pending | 2026-05-05 | ||
| #2405 | Priya Shah | $292.50 | Refunded | 2026-06-06 | ||
| #2406 | Alex Kim | $346.20 | Paid | 2026-07-07 | ||
| #2407 | Sara Chen | $399.90 | Pending | 2026-08-08 |
"use client";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
interface Order {
id: string;
order: string;
customer: string;
total: number;
status: string;
}
const data: Order[] = Array.from({ length: 24 }, (_, i) => ({
id: `ord-${i + 1}`,
order: `#${String(2400 + i)}`,
customer: ["Alex Kim", "Sara Chen", "Jordan Lee", "Maya Patel"][i % 4],
total: Math.round((i * 53.7 + 24) * 100) / 100,
status: ["Paid", "Pending", "Refunded"][i % 3],
}));
const columns: DataTableColumn<Order>[] = [
{ id: "order", header: "Order", accessor: (row) => row.order },
{ id: "customer", header: "Customer", accessor: (row) => row.customer },
{ id: "total", header: "Total", accessor: (row) => row.total, cell: (row) => `$${row.total.toFixed(2)}`, align: "right" },
{ id: "status", header: "Status", accessor: (row) => row.status },
];
export default function Example() {
return (
<div className="w-full p-6">
<DataTable
data={data}
columns={columns}
getRowId={(row) => row.id}
preset="advanced"
pageSize={8}
border
renderExpanded={(row) => (
<div className="text-xs opacity-80">
Order {row.order} for {row.customer} — status {row.status}.
</div>
)}
theme="dark"
/>
</div>
);
}Virtualized 10k rows
Variant 3 of 5| Event 1 | api | 12 | error | |
| Event 2 | web | 49 | ok | |
| Event 3 | worker | 86 | ok | |
| Event 4 | cron | 123 | ok | |
| Event 5 | api | 160 | ok | |
| Event 6 | web | 197 | ok | |
| Event 7 | worker | 234 | ok | |
| Event 8 | cron | 271 | ok | |
| Event 9 | api | 308 | ok | |
| Event 10 | web | 345 | ok | |
| Event 11 | worker | 382 | ok | |
| Event 12 | cron | 419 | ok | |
| Event 13 | api | 456 | ok | |
| Event 14 | web | 493 | error | |
| Event 15 | worker | 530 | ok |
"use client";
import { useMemo } from "react";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
interface EventRow {
id: string;
event: string;
service: string;
latency: number;
status: string;
}
const columns: DataTableColumn<EventRow>[] = [
{ id: "event", header: "Event", accessor: (row) => row.event },
{ id: "service", header: "Service", accessor: (row) => row.service },
{ id: "latency", header: "Latency (ms)", accessor: (row) => row.latency, align: "right" },
{ id: "status", header: "Status", accessor: (row) => row.status },
];
export default function Example() {
const data = useMemo<EventRow[]>(
() =>
Array.from({ length: 10000 }, (_, i) => ({
id: `evt-${i}`,
event: `Event ${i + 1}`,
service: ["api", "web", "worker", "cron"][i % 4],
latency: ((i * 37) % 900) + 12,
status: i % 13 === 0 ? "error" : "ok",
})),
[]
);
return (
<div className="w-full p-6">
<DataTable
data={data}
columns={columns}
getRowId={(row) => row.id}
preset="enterprise"
maxHeight={400}
rowHeight={44}
theme="dark"
/>
</div>
);
}Grouped rows + rowspan
Variant 4 of 5| Team | Name | Region | On call |
|---|---|---|---|
| Platform | Alex Kim | West | Yes |
| Platform | Maya Patel | No | |
| Platform | Sam Rivera | East | No |
| Product | Sara Chen | East | Yes |
| Product | Ryan Wu | Central | No |
| Growth | Jordan Lee | Central | No |
| Growth | Priya Shah | West | Yes |
"use client";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
interface Person {
id: string;
name: string;
team: string;
region: string;
oncall: string;
}
const data: Person[] = [
{ id: "1", name: "Alex Kim", team: "Platform", region: "West", oncall: "Yes" },
{ id: "2", name: "Maya Patel", team: "Platform", region: "West", oncall: "No" },
{ id: "3", name: "Sam Rivera", team: "Platform", region: "East", oncall: "No" },
{ id: "4", name: "Sara Chen", team: "Product", region: "East", oncall: "Yes" },
{ id: "5", name: "Ryan Wu", team: "Product", region: "Central", oncall: "No" },
{ id: "6", name: "Jordan Lee", team: "Growth", region: "Central", oncall: "No" },
];
const columns: DataTableColumn<Person>[] = [
{ id: "team", header: "Team", accessor: (row) => row.team },
{ id: "name", header: "Name", accessor: (row) => row.name },
{ id: "region", header: "Region", accessor: (row) => row.region, rowSpan: true },
{ id: "oncall", header: "On call", accessor: (row) => row.oncall },
];
export default function Example() {
return (
<div className="w-full p-6">
<DataTable data={data} columns={columns} getRowId={(row) => row.id} groupBy="team" border theme="dark" />
</div>
);
}Column groups + freeze
Variant 5 of 5| H1 | H2 | ||||||
|---|---|---|---|---|---|---|---|
| Alex Kim | 42 | 51 | 90 | 48 | 60 | 105 | Platform |
| Sara Chen | 38 | 47 | 88 | 52 | 58 | 102 | Product |
| Jordan Lee | 51 | 44 | 95 | 47 | 63 | 110 | Growth |
| Maya Patel | 45 | 55 | 92 | 50 | 61 | 108 | Platform |
"use client";
import { DataTable, type DataTableColumn } from "@/components/ui/data-table";
interface ReportRow {
id: string;
name: string;
q1: number;
q2: number;
q3: number;
q4: number;
owner: string;
}
const data: ReportRow[] = [
{ id: "1", name: "Alex Kim", q1: 42, q2: 51, q3: 48, q4: 60, owner: "Platform" },
{ id: "2", name: "Sara Chen", q1: 38, q2: 47, q3: 52, q4: 58, owner: "Product" },
{ id: "3", name: "Jordan Lee", q1: 51, q2: 44, q3: 47, q4: 63, owner: "Growth" },
];
const columns: DataTableColumn<ReportRow>[] = [
{ id: "name", header: "Name", accessor: (row) => row.name, freeze: "left", width: 140 },
{
id: "h1",
header: "H1",
accessor: () => null,
columns: [
{ id: "q1", header: "Q1", accessor: (row) => row.q1, align: "right" },
{ id: "q2", header: "Q2", accessor: (row) => row.q2, align: "right" },
],
},
{
id: "h2",
header: "H2",
accessor: () => null,
columns: [
{ id: "q3", header: "Q3", accessor: (row) => row.q3, align: "right" },
{ id: "q4", header: "Q4", accessor: (row) => row.q4, align: "right" },
],
},
{ id: "owner", header: "Owner", accessor: (row) => row.owner },
];
export default function Example() {
return (
<div className="w-full p-6">
<DataTable data={data} columns={columns} getRowId={(row) => row.id} sortable border theme="dark" />
</div>
);
}Props
| Prop | Type | Description |
|---|---|---|
data | T[] | Typed row objects; the component is generic over your row type. |
columns | DataTableColumn<T>[] | Column definitions: id, header, accessor (raw value for sort/search/group), optional cell renderer, sortType, searchable, width, align, freeze, nested columns (column groups), and rowSpan. |
getRowId | (row: T) => string | Required stable row id — selection, expansion, pinning, and windowing keys all hang off it. |
preset | "basic" | "advanced" | "enterprise" | Feature bundle. basic: search + pagination + sort. advanced: adds multi-sort, multi-select, expansion, sticky header. enterprise: advanced minus pagination so large data auto-virtualizes. Any explicit prop overrides the preset. |
searchable | boolean | Global debounced (150ms) case-insensitive search over searchable columns' accessor values. |
paginated | boolean | Client-side pagination; disables virtualization. |
pageSize | number | Rows per page when paginated. |
pageSizeOptions | number[] | Optional page-size selector values. |
sortable | boolean | Header click cycles asc, desc, none. Typed comparators (number/date/string) inferred from accessor values or set per column via sortType. |
multiSort | boolean | Shift-click adds secondary/tertiary sort keys with visible priority badges. |
onSort | (sort: DataTableSortRule[]) => void | Called with the full ordered rule list whenever sorting changes. |
selectable | boolean | Checkbox column; the tri-state header checkbox selects all filtered rows, not just the visible page or window. |
selectedIds | string[] | Controlled selection; omit for uncontrolled. |
onSelectionChange | (ids: string[]) => void | Selection change callback (fires in both controlled and uncontrolled modes). |
expandable | boolean | Chevron toggle column; requires renderExpanded. |
renderExpanded | (row: T) => React.ReactNode | Expanded panel content; works in both engines (the virtualizer measures expanded height). |
expandedIds | string[] | Controlled expansion; omit for uncontrolled. |
onExpandedChange | (ids: string[]) => void | Expansion change callback. |
groupBy | string | Column id to group rows by — collapsible group header rows; uses the standard engine. |
virtualized | boolean | Force an engine. Default is automatic: virtualized when flat data exceeds virtualizeThreshold and no groupBy/rowSpan/pagination is active. |
virtualizeThreshold | number | Row count above which flat data is windowed. |
rowHeight | number | Estimated row height in px used for windowing math. |
maxHeight | number | string | Scroll container height; the virtualized engine falls back to 480px when omitted. |
stickyHeader | boolean | Keep the header row(s) fixed while scrolling vertically. |
pinnedRows | string[] | Row ids kept visible directly below the header. |
theme | "light" | "dark" | Theme for default header/body colors when not overridden. |
border | boolean | Show table and cell borders. |
headerTextColor | string | Tailwind class for header text, e.g. text-neutral-900. |
bodyTextColor | string | Tailwind class for body cell text. |
headerBackground | string | Tailwind class for header background, e.g. bg-neutral-100. |
bodyBackground | string | Tailwind class for body background. |
className | string | Additional classes on the root wrapper. |