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 KimEngineerPlatformActive2023-01-09
Sara ChenDesignerProductActive2023-03-14
Jordan LeePMGrowthAway2023-06-02
Maya PatelEngineerPlatformActive2024-01-22
Ryan WuDesignerProductAway2024-02-11
Priya ShahPMGrowthActive2024-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
#2400Alex Kim$24.00Paid2026-01-01
#2401Sara Chen$77.70Pending2026-02-02
#2402Jordan Lee$131.40Refunded2026-03-03
#2403Maya Patel$185.10Paid2026-04-04
#2404Ryan Wu$238.80Pending2026-05-05
#2405Priya Shah$292.50Refunded2026-06-06
#2406Alex Kim$346.20Paid2026-07-07
#2407Sara Chen$399.90Pending2026-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 1api12error
Event 2web49ok
Event 3worker86ok
Event 4cron123ok
Event 5api160ok
Event 6web197ok
Event 7worker234ok
Event 8cron271ok
Event 9api308ok
Event 10web345ok
Event 11worker382ok
Event 12cron419ok
Event 13api456ok
Event 14web493error
Event 15worker530ok
"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
TeamNameRegionOn call
PlatformAlex KimWestYes
PlatformMaya PatelNo
PlatformSam RiveraEastNo
ProductSara ChenEastYes
ProductRyan WuCentralNo
GrowthJordan LeeCentralNo
GrowthPriya ShahWestYes
"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
H1H2
Alex Kim4251904860105Platform
Sara Chen3847885258102Product
Jordan Lee5144954763110Growth
Maya Patel4555925061108Platform
"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

PropTypeDescription
dataT[]Typed row objects; the component is generic over your row type.
columnsDataTableColumn<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) => stringRequired 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.
searchablebooleanGlobal debounced (150ms) case-insensitive search over searchable columns' accessor values.
paginatedbooleanClient-side pagination; disables virtualization.
pageSizenumberRows per page when paginated.
pageSizeOptionsnumber[]Optional page-size selector values.
sortablebooleanHeader click cycles asc, desc, none. Typed comparators (number/date/string) inferred from accessor values or set per column via sortType.
multiSortbooleanShift-click adds secondary/tertiary sort keys with visible priority badges.
onSort(sort: DataTableSortRule[]) => voidCalled with the full ordered rule list whenever sorting changes.
selectablebooleanCheckbox column; the tri-state header checkbox selects all filtered rows, not just the visible page or window.
selectedIdsstring[]Controlled selection; omit for uncontrolled.
onSelectionChange(ids: string[]) => voidSelection change callback (fires in both controlled and uncontrolled modes).
expandablebooleanChevron toggle column; requires renderExpanded.
renderExpanded(row: T) => React.ReactNodeExpanded panel content; works in both engines (the virtualizer measures expanded height).
expandedIdsstring[]Controlled expansion; omit for uncontrolled.
onExpandedChange(ids: string[]) => voidExpansion change callback.
groupBystringColumn id to group rows by — collapsible group header rows; uses the standard engine.
virtualizedbooleanForce an engine. Default is automatic: virtualized when flat data exceeds virtualizeThreshold and no groupBy/rowSpan/pagination is active.
virtualizeThresholdnumberRow count above which flat data is windowed.
rowHeightnumberEstimated row height in px used for windowing math.
maxHeightnumber | stringScroll container height; the virtualized engine falls back to 480px when omitted.
stickyHeaderbooleanKeep the header row(s) fixed while scrolling vertically.
pinnedRowsstring[]Row ids kept visible directly below the header.
theme"light" | "dark"Theme for default header/body colors when not overridden.
borderbooleanShow table and cell borders.
headerTextColorstringTailwind class for header text, e.g. text-neutral-900.
bodyTextColorstringTailwind class for body cell text.
headerBackgroundstringTailwind class for header background, e.g. bg-neutral-100.
bodyBackgroundstringTailwind class for body background.
classNamestringAdditional classes on the root wrapper.