DataTable
A feature-rich data table powered by TanStack Table with sorting, filtering, pagination, row selection, column visibility, and column pinning — rendered with Unified UI styling.
Basic
A feature-rich data table powered by TanStack Table with sorting, filtering, pagination, row selection, and column visibility — rendered with Unified UI
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
Installation
Install the component via the CLI in one command.
npx @work-rjkashyap/unified-ui add data-tablepnpm dlx @work-rjkashyap/unified-ui add data-tablenpx @work-rjkashyap/unified-ui add data-tablebunx @work-rjkashyap/unified-ui add data-tableIf you haven't initialized your project yet, run npx @work-rjkashyap/unified-ui init first. See the CLI docs for details.
Or install the full package
Use this approach if you prefer to install the entire design system as a dependency instead of copying individual components.
npm install @work-rjkashyap/unified-uipnpm add @work-rjkashyap/unified-uiyarn add @work-rjkashyap/unified-uibun add @work-rjkashyap/unified-uiAnatomy
import {
DataTable,
Badge,
} from "@work-rjkashyap/unified-ui";Defining Columns
Columns use TanStack Table's ColumnDef API. Each column defines how to access data and how to render headers and cells.
import { type ColumnDef } from "@work-rjkashyap/unified-ui";
type Person = {
name: string;
email: string;
age: number;
status: "active" | "inactive";
};
const columns: ColumnDef<Person>[] = [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "age",
header: "Age",
meta: { align: "right" },
},
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => (
<Badge
variant={getValue() === "active" ? "success" : "secondary"}
size="sm"
>
{getValue()}
</Badge>
),
},
];Using createColumnHelper
For better type inference without explicit generics on every column, use the createColumnHelper utility:
import { createColumnHelper } from "@work-rjkashyap/unified-ui";
const columnHelper = createColumnHelper<Person>();
const columns = [
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("email", {
header: "Email",
}),
columnHelper.accessor("age", {
header: "Age",
meta: { align: "right" },
}),
];Column Sizing
Control column widths with the size, minSize, and maxSize properties:
const columns: ColumnDef<Person>[] = [
{
accessorKey: "id",
header: "ID",
size: 80, // preferred width in pixels
minSize: 60, // minimum width
maxSize: 120, // maximum width
},
{
accessorKey: "name",
header: "Name",
size: 200,
},
{
accessorKey: "email",
header: "Email",
size: 280,
},
];TanStack Table uses a default column size of 150. The DataTable only
applies explicit width styles when the column size differs from 150, so
columns without a size prop will auto-size naturally via the browser.
Custom Cell Renderers
Column cells can render arbitrary React content. Use the cell property to create rich, interactive cells.
Alice Johnsonalice@example.com | Admin | Active | $2,500 |
Bob Smithbob@example.com | Editor | Active | $1,800 |
Charlie Browncharlie@example.com | Viewer | Inactive | $950 |
Diana Princediana@example.com | Admin | Active | $3,200 |
Eve Wilsoneve@example.com | Editor | Pending | $1,400 |
Frank Castlefrank@example.com | Viewer | Inactive | $600 |
Combined User Cell
Stack multiple fields into a single cell for a compact layout:
{
accessorKey: "name",
header: "User",
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">
{row.original.name}
</span>
<span className="text-xs text-muted-foreground">
{row.original.email}
</span>
</div>
),
}Status Indicator with Dot
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => {
const status = getValue() as string;
const isActive = status === "Active";
return (
<div className="flex items-center gap-1.5">
<span
className={`inline-block size-2 rounded-full ${
isActive ? "bg-green-500"
: status === "Pending" ? "bg-yellow-500"
: "bg-zinc-400"
}`}
/>
<span className="text-sm">{status}</span>
</div>
);
},
}Conditional Color on Numeric Values
{
accessorKey: "amount",
header: "Amount",
cell: ({ getValue }) => {
const amount = getValue() as number;
return (
<span className={`font-mono text-sm tabular-nums ${
amount >= 2000
? "text-green-600 dark:text-green-400"
: "text-foreground"
}`}>
${amount.toLocaleString()}
</span>
);
},
meta: { align: "right", cellClassName: "font-mono tabular-nums" },
}Action Column
Add an actions column with buttons or dropdown menus:
{
id: "actions",
header: "",
size: 60,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="size-8 p-0">
<MoreHorizontal className="size-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(row.original)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(row.original)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}Sorting
Enable client-side sorting with the sorting prop. Click column headers to toggle sort direction.
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
| INV-006 | Frank Castle | frank@example.com | Viewer | Inactive | $600 |
<DataTable data={users} columns={columns} sorting />Multi-Column Sorting
Hold Shift and click additional column headers to sort by multiple columns simultaneously:
<DataTable data={users} columns={columns} sorting multiSort />Disable Sorting on a Column
Prevent a column from being sortable by setting enableSorting: false:
{
accessorKey: "actions",
header: "",
enableSorting: false,
}Custom Sort Functions
TanStack Table supports custom sorting functions via the sortingFn property on a column definition:
{
accessorKey: "date",
header: "Date",
sortingFn: (rowA, rowB, columnId) => {
const a = new Date(rowA.getValue(columnId)).getTime();
const b = new Date(rowB.getValue(columnId)).getTime();
return a - b;
},
}Controlled Sorting
Manage sort state externally for integration with URL search params or external state:
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
<DataTable
data={users}
columns={columns}
sorting
sortingState={sorting}
onSortingChange={setSorting}
/>;Filtering
Enable client-side filtering with the filtering prop. Add a global search bar with showGlobalFilter.
| Invoice | Name | Role | Status | Amount | |
|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
<DataTable
data={users}
columns={columns}
filtering
showGlobalFilter
globalFilterPlaceholder="Search users..."
/>Per-Column Filters
Enable column-specific filter inputs by setting meta.filterable: true on individual columns. Each filterable column gets an inline text input rendered directly below its header text.
const columns: ColumnDef<Person>[] = [
{
accessorKey: "name",
header: "Name",
meta: {
filterable: true,
filterPlaceholder: "Search names...",
},
},
{
accessorKey: "role",
header: "Role",
meta: {
filterable: true,
filterPlaceholder: "Filter role...",
},
},
{
accessorKey: "email",
header: "Email",
},
];
<DataTable data={users} columns={columns} filtering />;Combining Global and Column Filters
Use both at the same time — the global filter applies first, then column filters narrow down further:
<DataTable
data={users}
columns={columnsWithFilterableMeta}
filtering
showGlobalFilter
globalFilterPlaceholder="Quick search..."
/>Custom Filter Functions
TanStack Table supports custom filter functions via the filterFn property:
{
accessorKey: "amount",
header: "Amount",
filterFn: (row, columnId, filterValue) => {
const amount = row.getValue<number>(columnId);
const [min, max] = filterValue as [number, number];
return amount >= min && amount <= max;
},
}Controlled Filtering
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
<DataTable
data={users}
columns={columns}
filtering
showGlobalFilter
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
/>;Pagination
Enable client-side pagination with the pagination prop. Control the page size with pageSize.
| Invoice | Name | Role | Status | Amount | |
|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
<DataTable data={users} columns={columns} pagination pageSize={20} />The pagination bar includes:
- Row count — total rows or selected row count
- Page size selector — dropdown to change rows per page
- Page navigation — first, previous, next, last buttons with page indicator
Custom Page Size Options
<DataTable
data={users}
columns={columns}
pagination
pageSize={25}
pageSizeOptions={[10, 25, 50, 100]}
/>Hide Page Size Selector
<DataTable data={users} columns={columns} pagination pageSizeOptions={false} />Controlled Pagination
Manage pagination state externally (e.g. for URL-based page tracking):
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
<DataTable
data={users}
columns={columns}
pagination
paginationState={pagination}
onPaginationChange={setPagination}
/>;Row Selection
Enable row selection with the rowSelection prop. Choose between "single" and "multi" modes.
| Invoice | Name | Role | Status | Amount | ||
|---|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 | |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 | |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 | |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 | |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 | |
| INV-006 | Frank Castle | frank@example.com | Viewer | Inactive | $600 |
{
/* Multi-select with checkboxes */
}
<DataTable data={users} columns={columns} rowSelection="multi" />;
{
/* Single-select (radio-style) */
}
<DataTable data={users} columns={columns} rowSelection="single" />;When enabled, a checkbox column is automatically prepended:
- Multi mode — header checkbox toggles "select all" for the current page.
- Single mode — no header checkbox; clicking a new row deselects the previous one.
Selected Row Callback
The onSelectedRowsChange convenience callback gives you the full Row objects:
<DataTable
data={users}
columns={columns}
rowSelection="multi"
onSelectedRowsChange={(rows) => {
console.log(
"Selected:",
rows.map((r) => r.original),
);
}}
/>Controlled Row Selection
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
<DataTable
data={users}
columns={columns}
rowSelection="multi"
rowSelectionState={rowSelection}
onRowSelectionChange={setRowSelection}
onSelectedRowsChange={(rows) => {
console.log(
"Selected:",
rows.map((r) => r.original),
);
}}
/>;Conditional Row Selection
Disable selection for specific rows based on data:
<DataTable
data={users}
columns={columns}
rowSelection="multi"
enableRowSelection={(row) => row.original.status === "Active"}
/>Disabled rows render with a dimmed, non-interactive checkbox.
Custom Row ID
By default, row selection uses the row index as a key. Pass getRowId for stable, data-driven IDs that survive re-renders and re-sorting:
<DataTable
data={users}
columns={columns}
rowSelection="multi"
getRowId={(row) => row.id}
/>Always use getRowId when your data can change order (e.g. sorting,
filtering, live updates). Index-based selection will silently select wrong
rows after a re-sort.
Column Visibility
Enable column toggling with the columnVisibility prop. A Columns dropdown button appears in the toolbar.
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
<DataTable data={users} columns={columns} columnVisibility />Controlled Column Visibility
Pre-hide columns and react to changes:
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
email: false, // Hide email column by default
});
<DataTable
data={users}
columns={columns}
columnVisibility
columnVisibilityState={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
/>;Prevent Hiding a Column
Set enableHiding: false on a column definition to prevent it from appearing in the visibility toggle:
const columns: ColumnDef<Person>[] = [
{
accessorKey: "name",
header: "Name",
enableHiding: false, // Always visible — not shown in dropdown
},
{
accessorKey: "email",
header: "Email",
// Can be toggled
},
];The selection checkbox column (id: "select") is always excluded from the visibility toggle automatically.
Column Pinning
Pin columns to the left or right edge so they remain visible during horizontal scrolling. This is especially useful for ID columns or action buttons on wide tables.
| Invoice | Name | Role | Status | Amount | |
|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
<DataTable
data={users}
columns={columns}
columnPinning={{ left: ["id"], right: ["amount"] }}
responsive
/>Controlled Column Pinning
import type { ColumnPinningState } from "@tanstack/react-table";
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ["select", "id"],
right: ["actions"],
});
<DataTable
data={users}
columns={columns}
rowSelection="multi"
columnPinning={columnPinning}
onColumnPinningChange={setColumnPinning}
responsive
/>;Column pinning is applied via TanStack Table's columnPinning state. The
visual "sticky" behavior depends on the columns having explicit size
values and the table being inside a scrollable container (responsive={true} ).
Table Appearance
Density
The density prop controls the vertical padding on all rows. Three options are available:
compact
| Invoice | Name | Role | |
|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin |
| INV-002 | Bob Smith | bob@example.com | Editor |
| INV-003 | Charlie Brown | charlie@example.com | Viewer |
comfortable (default)
| Invoice | Name | Role | |
|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin |
| INV-002 | Bob Smith | bob@example.com | Editor |
| INV-003 | Charlie Brown | charlie@example.com | Viewer |
<DataTable data={users} columns={columns} density="compact" />
<DataTable data={users} columns={columns} density="comfortable" />| Density | Padding | Best For |
|---|---|---|
compact | Reduced | Data-dense tables, logs, admin dashboards |
comfortable | Default | General-purpose, user-facing content |
Striped & Bordered
| Invoice | Name | Role | |
|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin |
| INV-002 | Bob Smith | bob@example.com | Editor |
| INV-003 | Charlie Brown | charlie@example.com | Viewer |
| INV-004 | Diana Prince | diana@example.com | Admin |
| INV-005 | Eve Wilson | eve@example.com | Editor |
<DataTable data={users} columns={columns} striped bordered hoverable />| Prop | Effect |
|---|---|
striped | Alternating row background using --muted on even rows |
bordered | Visible borders between all cells using --border-muted |
hoverable | Row background highlight on hover (enabled by default) |
Combining Appearance Props
<DataTable
data={users}
columns={columns}
density="compact"
striped
bordered
hoverable
sorting
pagination
pageSize={25}
/>Responsive Behavior
By default, DataTable wraps the <table> in a horizontally-scrollable container when responsive={true} (the default). This prevents layout breaks on narrow viewports.
| Invoice | Full Name | Email Address | Role | Account Status | Total Amount | Created At | Last Updated |
|---|---|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 | 2024-01-15 | 2025-06-10 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 | 2024-01-15 | 2025-06-10 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 | 2024-01-15 | 2025-06-10 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 | 2024-01-15 | 2025-06-10 |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 | 2024-01-15 | 2025-06-10 |
{
/* Responsive scrolling (default) */
}
<DataTable data={users} columns={wideColumns} responsive />;
{
/* Disable responsive wrapping — table will overflow its parent */
}
<DataTable data={users} columns={wideColumns} responsive={false} />;Add a custom class to the scroll wrapper with wrapperClassName:
<DataTable
data={users}
columns={columns}
responsive
wrapperClassName="max-h-[400px] overflow-y-auto"
/>When columns have explicit size values and the total exceeds the container
width, the scrollbar activates automatically. Pair with column pinning for
the best experience on wide tables.
Loading State
Show a skeleton placeholder while data is being fetched. The skeleton adapts to the current density and visible column count.
| Invoice | Name | Role | |
|---|---|---|---|
<DataTable data={users} columns={columns} loading={isLoading} />The loading skeleton renders 5 animated rows by default. Each row contains pulsing bars with varying widths for a realistic placeholder appearance.
Pattern: Loading with Retained Columns
The skeleton always uses the column count from your columns prop, so headers remain visible during loading even when data is empty:
const [data, setData] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then((users) => {
setData(users);
setLoading(false);
});
}, []);
<DataTable
data={data}
columns={columns}
loading={loading}
sorting
pagination
/>;Empty State
Customize the message shown when no data matches.
| Invoice | Name | Role | |
|---|---|---|---|
No users foundTry adjusting your search or filters. | |||
<DataTable
data={[]}
columns={columns}
emptyState={
<div className="flex flex-col items-center gap-1.5 py-4">
<span className="text-sm font-medium">No users found</span>
<span className="text-xs text-muted-foreground">
Try adjusting your search or filters.
</span>
</div>
}
/>If emptyState is not provided, a default "No results." message is displayed.
Empty State with Action
<DataTable
data={filteredData}
columns={columns}
filtering
showGlobalFilter
emptyState={
<div className="flex flex-col items-center gap-3 py-8">
<SearchX className="size-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">No matching results</p>
<p className="text-xs text-muted-foreground mt-1">
Try a different search term or clear your filters.
</p>
</div>
<Button variant="secondary" size="sm" onClick={clearFilters}>
Clear filters
</Button>
</div>
}
/>Caption
Add an accessible caption that describes the table content. The caption renders as a native <caption> element for full screen reader support.
| Invoice | Name | Role | Status | Amount | |
|---|---|---|---|---|---|
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 |
<DataTable
data={invoices}
columns={columns}
caption="Q4 2024 invoice summary — all amounts in USD."
/>The caption is rendered visually below the table body, styled with text-muted-foreground. It also accepts ReactNode:
<DataTable
data={invoices}
columns={columns}
caption={
<span>
Showing data from <strong>Jan 1</strong> to{" "}
<strong>Dec 31, 2024</strong>
</span>
}
/>Row Click Handler
Handle row clicks for navigation or detail views:
<DataTable
data={users}
columns={columns}
onRowClick={(row, event) => {
router.push(`/users/${row.original.id}`);
}}
/>Rows with an onRowClick handler automatically receive a cursor-pointer class.
When using row selection alongside onRowClick, be mindful of interaction
conflicts. Clicking a checkbox triggers both handlers. Use event.target to
differentiate if needed.
Toolbar & Footer Slots
Render custom content above and below the table. Both props accept a static ReactNode or a render function that receives the TanStack Table instance for dynamic content.
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 | |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 | |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 | |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 | |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
Toolbar
The toolbar renders above the table, in the same row as the global filter and column visibility toggle. Custom toolbar items are placed between the search input and the Columns button.
<DataTable
data={users}
columns={columns}
sorting
filtering
showGlobalFilter
columnVisibility
toolbar={(table) => {
const selected = table.getFilteredSelectedRowModel().rows;
return selected.length > 0 ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{selected.length} selected
</span>
<Button
variant="secondary"
size="sm"
onClick={() =>
handleExport(selected.map((r) => r.original))
}
>
Export Selected
</Button>
<Button
variant="danger"
size="sm"
onClick={() =>
handleDelete(selected.map((r) => r.original))
}
>
Delete
</Button>
</div>
) : null;
}}
/>Static Toolbar
<DataTable
data={users}
columns={columns}
toolbar={
<Button variant="secondary" size="sm">
<Download className="size-3.5 mr-1.5" />
Export CSV
</Button>
}
/>Footer Slot
The footer renders below the table (and below the pagination bar, if enabled):
<DataTable
data={users}
columns={columns}
pagination
footer={(table) => (
<div className="flex items-center justify-between px-2 text-xs text-muted-foreground">
<span>
Showing {table.getRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} rows
</span>
<span>Last updated: 2 minutes ago</span>
</div>
)}
/>Table Footer Row
Render aggregation or summary rows using the footer property on column definitions. Enable rendering with showFooter.
const columns: ColumnDef<Invoice>[] = [
{
accessorKey: "item",
header: "Item",
footer: "Total",
},
{
accessorKey: "amount",
header: "Amount",
cell: ({ getValue }) => `$${getValue()}`,
footer: ({ table }) => {
const total = table
.getFilteredRowModel()
.rows.reduce(
(sum, row) => sum + row.getValue<number>("amount"),
0,
);
return `$${total.toLocaleString()}`;
},
meta: { align: "right" },
},
];
<DataTable data={invoices} columns={columns} showFooter />;The footer is rendered inside a <tfoot> element for proper semantic HTML. Footer cells support the same ReactNode | function pattern as header and cell renderers.
Accessing the Table Instance
Use onTableInstance to get a reference to the underlying TanStack Table instance. This is useful for imperative actions from outside the DataTable.
import type { Table as TanStackTable } from "@tanstack/react-table";
function UsersPage() {
const tableRef = useRef<TanStackTable<User> | null>(null);
const selectAll = () => {
tableRef.current?.toggleAllRowsSelected(true);
};
const clearSelection = () => {
tableRef.current?.resetRowSelection();
};
const goToFirstPage = () => {
tableRef.current?.firstPage();
};
return (
<div>
<div className="flex gap-2 mb-3">
<Button size="sm" onClick={selectAll}>
Select All
</Button>
<Button size="sm" variant="secondary" onClick={clearSelection}>
Clear
</Button>
<Button size="sm" variant="secondary" onClick={goToFirstPage}>
First Page
</Button>
</div>
<DataTable
data={users}
columns={columns}
rowSelection="multi"
pagination
onTableInstance={(table) => {
tableRef.current = table;
}}
/>
</div>
);
}The onTableInstance callback fires on every render. Store the reference in
a useRef rather than state to avoid infinite re-render loops.
useDataTable Hook
For advanced controlled usage, the useDataTable hook manages all table state in one place. Spread tableProps into <DataTable /> for fully controlled mode.
import { DataTable, useDataTable, type ColumnDef } from "@work-rjkashyap/unified-ui";
function UsersPage({ data }: { data: User[] }) {
const columns: ColumnDef<User>[] = [...];
const { tableProps, sorting, rowSelection, reset } = useDataTable({
data,
columns,
initialSorting: [{ id: "name", desc: false }],
initialPagination: { pageSize: 20 },
});
return (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="text-sm text-muted-foreground">
{Object.keys(rowSelection).length} selected
</span>
<Button variant="ghost" size="sm" onClick={reset}>
Reset All
</Button>
</div>
<DataTable
data={data}
columns={columns}
sorting
pagination
filtering
showGlobalFilter
rowSelection="multi"
columnVisibility
striped
hoverable
{...tableProps}
/>
</div>
);
}useDataTable Options
| Option | Type | Default | Description |
|---|---|---|---|
data | TData[] | required | The data array. |
columns | ColumnDef<TData>[] | required | Column definitions. |
initialSorting | SortingState | [] | Initial sort state. |
initialPagination | Partial<PaginationState> | { pageIndex: 0, pageSize: 10 } | Initial pagination. |
initialColumnFilters | ColumnFiltersState | [] | Initial column filters. |
initialRowSelection | RowSelectionState | {} | Initial selected rows. |
initialColumnVisibility | VisibilityState | {} | Initial column visibility. |
initialGlobalFilter | string | "" | Initial global filter value. |
useDataTable Return
| Property | Type | Description |
|---|---|---|
sorting | SortingState | Current sort state. |
onSortingChange | OnChangeFn<SortingState> | Sort state setter (pass to controlled props). |
globalFilter | string | Current global filter value. |
onGlobalFilterChange | OnChangeFn<string> | Global filter setter. |
columnFilters | ColumnFiltersState | Current column filters. |
onColumnFiltersChange | OnChangeFn<ColumnFiltersState> | Column filter setter. |
pagination | PaginationState | Current pagination state. |
onPaginationChange | OnChangeFn<PaginationState> | Pagination setter. |
rowSelection | RowSelectionState | Current row selection map. |
onRowSelectionChange | OnChangeFn<RowSelectionState> | Row selection setter. |
columnVisibility | VisibilityState | Current column visibility map. |
onColumnVisibilityChange | OnChangeFn<VisibilityState> | Column visibility setter. |
tableProps | object | Spread into <DataTable /> for controlled mode. |
reset | () => void | Reset all state to initial values. |
The tableProps object contains all the *State and on*Change props pre-wired, so you only need a single spread:
const { tableProps } = useDataTable({ data, columns });
// Equivalent to manually passing:
// sortingState, onSortingChange, globalFilter, onGlobalFilterChange,
// columnFilters, onColumnFiltersChange, paginationState, onPaginationChange,
// rowSelectionState, onRowSelectionChange, columnVisibilityState, onColumnVisibilityChange
<DataTable {...tableProps} data={data} columns={columns} sorting pagination />;Server-Side Data Pattern
DataTable is optimized for client-side operations, but you can integrate it with server-side data fetching by disabling the built-in client models and managing state externally.
"use client";
import { useEffect, useState } from "react";
import {
DataTable,
type ColumnDef,
type SortingState,
type PaginationState,
} from "@work-rjkashyap/unified-ui";
function ServerSideTable() {
const [data, setData] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
useEffect(() => {
setLoading(true);
const sortParam = sorting[0]
? `&sort=${sorting[0].id}&order=${sorting[0].desc ? "desc" : "asc"}`
: "";
fetch(
`/api/users?page=${pagination.pageIndex}&size=${pagination.pageSize}${sortParam}`,
)
.then((res) => res.json())
.then(({ rows, total }) => {
setData(rows);
setTotalCount(total);
setLoading(false);
});
}, [sorting, pagination]);
return (
<DataTable
data={data}
columns={columns}
loading={loading}
// Sorting — controlled, server handles the actual sort
sorting
sortingState={sorting}
onSortingChange={setSorting}
// Pagination — controlled, server handles page slicing
pagination
paginationState={pagination}
onPaginationChange={setPagination}
// Don't pass filtering/showGlobalFilter unless you handle it server-side too
hoverable
striped
/>
);
}When using server-side pagination, TanStack Table's pageCount is derived
from your data array length, which may not match the true total. For correct
page counts, you may need to pass manualPagination and pageCount
directly via the table instance. Use onTableInstance to configure this.
Full Kitchen Sink Example
All features enabled simultaneously:
| INV-001 | Alice Johnson | alice@example.com | Admin | Active | $2,500 | |
| INV-002 | Bob Smith | bob@example.com | Editor | Active | $1,800 | |
| INV-003 | Charlie Brown | charlie@example.com | Viewer | Inactive | $950 | |
| INV-004 | Diana Prince | diana@example.com | Admin | Active | $3,200 | |
| INV-005 | Eve Wilson | eve@example.com | Editor | Pending | $1,400 |
<DataTable
data={users}
columns={columns}
sorting
multiSort
filtering
showGlobalFilter
globalFilterPlaceholder="Search everything..."
pagination
pageSize={20}
rowSelection="multi"
getRowId={(row) => row.id}
columnVisibility
striped
hoverable
density="compact"
caption="User management table"
showFooter
onRowClick={(row) => router.push(`/users/${row.original.id}`)}
onSelectedRowsChange={(rows) => setSelected(rows)}
toolbar={(table) => {
const count = table.getFilteredSelectedRowModel().rows.length;
return count > 0 ? (
<Button variant="danger" size="sm">
Delete {count} row(s)
</Button>
) : null;
}}
footer={(table) => (
<p className="text-xs text-muted-foreground px-2">
{table.getFilteredRowModel().rows.length} total records
</p>
)}
/>Faceted Filters
Faceted filters add pill-style toolbar buttons that open a popover with checkbox options derived from a column's unique values. They're ideal for filtering categorical data like status, priority, or labels.
| Task | |||||||
|---|---|---|---|---|---|---|---|
| TASK-7493 | featureThe OCR matrix is down, navigate the virtual feed so we can program the ... | ◑In-Progress | ↓Low | 14 | February 28, 2026 | ||
| TASK-2048 | enhancementI'll back up the virtual VGA sensor, that should protocol the SDD applicati... | ◑In-Progress | ↓Low | 19 | February 28, 2026 | ||
| TASK-8557 | bugTry to hack the GB transmitter, maybe it will generate the multi-byte firew... | ◎Todo | ↓Low | 24 | February 28, 2026 | ||
| TASK-4375 | enhancementIf we bypass the interface, we can get to the THX panel through the back-... | ◑In-Progress | ↓Low | 19 | February 27, 2026 | ||
| TASK-3202 | documentationI'll program the online HDD array, that should bus the DRAM matrix! | ◑In-Progress | ↓Low | 10 | February 27, 2026 | ||
| TASK-2496 | featureConnecting the pixel won't do anything, we need to connect the multi-byt... | ◑In-Progress | ↓Low | 19 | February 27, 2026 | ||
| TASK-3513 | featureYou can't input the feed without generating the virtual VGA card! | ◉Done | ↓Low | 18 | February 27, 2026 | ||
| TASK-4272 | featureProgramming the protocol won't do anything, we need to calculate the sol... | ◑In-Progress | →Medium | 11 | February 27, 2026 | ||
| TASK-8142 | enhancementUse the primary GB capacitor, then you can parse the bluetooth microchip! | ◑In-Progress | ↓Low | 22 | February 27, 2026 | ||
| TASK-5700 | featureI'll compress the mobile GB card, that should alarm the UDP application! | ◑In-Progress | →Medium | 8 | February 27, 2026 |
Defining Faceted Filters
Pass an array of DataTableFacetedFilter objects to the facetedFilters prop:
import {
DataTable,
type DataTableFacetedFilter,
type ColumnDef,
} from "@work-rjkashyap/unified-ui";
const facetedFilters: DataTableFacetedFilter[] = [
{
columnId: "status",
title: "Status",
icon: <CircleDot className="size-3.5" />,
options: [
{ label: "Todo", value: "Todo", icon: <span>◎</span> },
{
label: "In-Progress",
value: "In-Progress",
icon: <span>◑</span>,
},
{ label: "Done", value: "Done", icon: <span>◉</span> },
{ label: "Cancelled", value: "Cancelled", icon: <span>◌</span> },
],
},
{
columnId: "priority",
title: "Priority",
icon: <ArrowUpDown className="size-3.5" />,
options: [
{ label: "Low", value: "Low", icon: <span>↓</span> },
{ label: "Medium", value: "Medium", icon: <span>→</span> },
{ label: "High", value: "High", icon: <span>↑</span> },
],
},
];
<DataTable
data={tasks}
columns={taskColumns}
sorting
multiSort
filtering
showGlobalFilter
globalFilterPlaceholder="Filter tasks..."
pagination
pageSize={10}
rowSelection="multi"
columnVisibility
hoverable
density="compact"
facetedFilters={facetedFilters}
/>;DataTableFacetedFilter Reference
| Property | Type | Required | Description |
|---|---|---|---|
columnId | string | Yes | The column ID to filter on (must match a ColumnDef accessorKey or id). |
title | string | Yes | Display label for the filter button. |
icon | ReactNode | No | Icon rendered before the title in the pill button. |
options | { label: string; value: string; icon?: ReactNode }[] | No | Explicit list of filter options. If omitted, options are derived from the column's unique values. |
Faceted filters require the column to have a filterFn that accepts an array of values (e.g. (row, id, value: string[]) => value.includes(row.getValue(id))). Without this, selected filter values won't match rows correctly.
Header Menu
When you set enableHeaderMenu: true in a column's meta, clicking the column header opens a dropdown menu with Sort Ascending, Sort Descending, and Hide Column actions instead of the default toggle-sort behavior.
const columns: ColumnDef<Task>[] = [
{
accessorKey: "status",
header: "Status",
meta: {
enableHeaderMenu: true,
} satisfies DataTableColumnMeta,
},
];Sort Badge & View Button
When faceted filters are enabled, the toolbar automatically includes:
- Sort Badge — Shows the number of active sort columns. Click to clear all sorts or remove individual ones.
- View Button — Opens a dropdown to toggle column visibility directly from the toolbar.
These controls appear alongside the global filter and faceted filter pills, giving the user a complete data exploration toolbar without any extra configuration.
Column Meta
Use the meta field on column definitions to control Unified UI–specific behavior:
const columns: ColumnDef<Person>[] = [
{
accessorKey: "name",
header: "Name",
meta: {
align: "left", // "left" | "center" | "right"
sticky: true, // Sticky header for this column
filterable: true, // Show per-column filter input
filterPlaceholder: "…", // Filter input placeholder
headerClassName: "…", // Extra class for <th>
cellClassName: "…", // Extra class for <td>
},
},
];DataTableColumnMeta Reference
| Property | Type | Default | Description |
|---|---|---|---|
align | "left" | "center" | "right" | "left" | Text alignment for header and cells. |
sticky | boolean | false | Sticky header for the column. |
filterable | boolean | false | Show a per-column filter input under the header. |
filterPlaceholder | string | "Filter..." | Placeholder for the column filter input. |
headerClassName | string | — | Additional CSS class for the header cell. |
cellClassName | string | — | Additional CSS class for body cells. |
Data Attributes
The DataTable and its sub-elements emit semantic data-ds-* attributes for styling hooks, testing, and DevTools inspection:
| Attribute | Element | Description |
|---|---|---|
data-ds-component="data-table" | Root wrapper | Identifies the DataTable root |
data-ds-component="data-table-toolbar" | Toolbar container | Toolbar area above the table |
data-ds-component="data-table-search" | Global filter input | The search input element |
data-ds-component="data-table-column-filter" | Column filter input | Per-column filter input under header |
data-ds-component="data-table-column-toggle" | Columns button | Column visibility toggle button |
data-ds-component="data-table-column-menu" | Columns dropdown | Column visibility dropdown menu |
data-ds-component="data-table-pagination" | Pagination bar | Pagination controls container |
data-ds-row-index="even" / "odd" | Table row | Row parity — useful for custom styling |
Targeting in CSS
/* Style the DataTable wrapper */
[data-ds-component="data-table"] {
/* custom styles */
}
/* Style only even rows */
[data-ds-row-index="even"] {
background: var(--custom-row-bg);
}Targeting in Tests
// Testing library
screen.getByTestId is not needed — use data attributes:
const table = container.querySelector('[data-ds-component="data-table"]');
const searchInput = container.querySelector('[data-ds-component="data-table-search"]');
const paginationBar = container.querySelector('[data-ds-component="data-table-pagination"]');Props
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
data | TData[] | required | Array of data objects. |
columns | ColumnDef<TData>[] | required | TanStack Table column definitions. |
getRowId | (row: TData, index: number) => string | Row index | Custom row ID accessor for stable keys. |
sorting | boolean | false | Enable client-side sorting. |
sortingState | SortingState | — | Controlled sorting state. |
onSortingChange | OnChangeFn<SortingState> | — | Controlled sorting callback. |
multiSort | boolean | false | Enable multi-column sorting (Shift+click). |
filtering | boolean | false | Enable client-side filtering. |
showGlobalFilter | boolean | false | Show global search input in toolbar. |
globalFilterPlaceholder | string | "Search..." | Global filter placeholder text. |
globalFilter | string | — | Controlled global filter value. |
onGlobalFilterChange | OnChangeFn<string> | — | Controlled global filter callback. |
columnFilters | ColumnFiltersState | — | Controlled column filters. |
onColumnFiltersChange | OnChangeFn<ColumnFiltersState> | — | Controlled column filters callback. |
pagination | boolean | false | Enable client-side pagination. |
pageSize | number | 10 | Rows per page. |
paginationState | PaginationState | — | Controlled pagination state. |
onPaginationChange | OnChangeFn<PaginationState> | — | Controlled pagination callback. |
pageSizeOptions | number[] | false | [10, 20, 30, 50, 100] | Page size options. false hides the selector. |
rowSelection | "single" | "multi" | false | false | Row selection mode. |
rowSelectionState | RowSelectionState | — | Controlled selection state. |
onRowSelectionChange | OnChangeFn<RowSelectionState> | — | Controlled selection callback. |
onSelectedRowsChange | (rows: Row<TData>[]) => void | — | Convenience callback with full Row objects. |
enableRowSelection | boolean | (row: Row) => boolean | true | Enable/disable row selection per-row. |
columnVisibility | boolean | false | Enable column visibility toggling. |
columnVisibilityState | VisibilityState | — | Controlled visibility state. |
onColumnVisibilityChange | OnChangeFn<VisibilityState> | — | Controlled visibility callback. |
columnPinning | ColumnPinningState | — | Controlled column pinning state. |
onColumnPinningChange | OnChangeFn<ColumnPinningState> | — | Controlled column pinning callback. |
density | "compact" | "comfortable" | "comfortable" | Row height density. |
striped | boolean | false | Alternating row backgrounds. |
hoverable | boolean | true | Highlight rows on hover. |
bordered | boolean | false | Borders between cells. |
responsive | boolean | true | Horizontal scroll wrapper. |
loading | boolean | false | Show loading skeleton. |
emptyState | ReactNode | "No results." | Custom empty state content. |
caption | ReactNode | — | Accessible table caption (<caption>). |
showFooter | boolean | false | Render column footer definitions in <tfoot>. |
toolbar | ReactNode | (table) => ReactNode | — | Custom toolbar content (above table). |
footer | ReactNode | (table) => ReactNode | — | Custom footer content (below table). |
className | string | — | Root wrapper classes. |
tableClassName | string | — | <table> element classes. |
wrapperClassName | string | — | Responsive scroll wrapper classes. |
onRowClick | (row: Row, event: MouseEvent) => void | — | Row click handler. |
onTableInstance | (table: Table) => void | — | Exposes the TanStack Table instance. |
Accessibility
DataTable renders using the same semantic Table primitives (table,
thead, tbody, tfoot, tr, th, td) ensuring full screen reader
support without additional configuration.
- Semantic HTML — Uses native
<table>elements for automatic screen reader table navigation. - Sort indicators — Sortable column headers include
aria-sortattributes (ascending,descending,none). Sort toggle buttons have descriptivearia-labeltext. - Selection checkboxes — Each row checkbox has an
aria-label(e.g. "Select row 3"). The header checkbox is labeled "Select all rows". Indeterminate state is correctly set when some (but not all) rows are selected. - Selected rows — Active selections are marked with
aria-selected="true"on the row. - Focus rings — All interactive elements (checkboxes, pagination buttons, filter inputs, column toggle, sort headers) display visible focus rings using
--focus-ring. - Keyboard navigation — Tab through interactive elements in reading order. Sort headers respond to Enter/Space. The column visibility dropdown closes on Escape.
- Caption — The
captionprop renders a native<caption>element for table description. - Column visibility menu — Uses
role="menu"with keyboard-dismissable overlay. - Disabled states — Disabled checkboxes and pagination buttons include
disabledattribute and visual dimming.
Design Tokens
Every visual property in DataTable comes from CSS custom properties. Override these tokens to restyle the entire component without touching its source.
| Token | Usage |
|---|---|
--primary | Checkbox accent color, focus ring base |
--border | Table borders, input borders, pagination buttons |
--border-muted | Row dividers, column filter input borders |
--muted | Striped row background, header background, skeleton |
--foreground | Cell text, button text, header text |
--muted-foreground | Caption, pagination info, placeholder text |
--background | Input backgrounds, button backgrounds |
--popover | Column visibility dropdown background |
--popover-foreground | Column visibility dropdown text |
--primary-muted | Selected row background highlight |
--focus-ring | Focus ring on all interactive elements |
--radius-sm | Filter inputs, checkboxes, skeleton bars |
--radius-md | Search input, pagination buttons, dropdown |
--shadow-md | Column visibility dropdown shadow |
--duration-fast | Hover and focus transition speed |
--z-dropdown | Column visibility dropdown z-index (default: 40) |
Scoped Token Override Example
/* Make DataTable use a blue accent instead of the global primary */
[data-ds-component="data-table"] {
--primary: oklch(0.546 0.245 262.881);
--primary-muted: oklch(0.932 0.032 255.585);
--focus-ring: oklch(0.623 0.214 259.815);
}Relationship to Table
DataTable is a higher-level component built on top of the existing Table primitives. Here's how they compare:
| Aspect | Table | DataTable |
|---|---|---|
| Level | Low-level primitives | High-level, feature-rich |
| Data | Manual row rendering via JSX | Declarative via data + columns |
| Sorting | Visual-only sortDirection prop | Full client-side sorting logic |
| Filtering | Not included | Global + per-column filtering |
| Pagination | Not included | Built-in pagination bar |
| Selection | Not included | Single/multi row selection |
| Column visibility | Not included | Toggle dropdown |
| Column pinning | Not included | Left/right pinning |
| Dependencies | None (pure HTML/CSS) | @tanstack/react-table |
| Bundle size | ~2 KB | ~15 KB (+ TanStack Table) |
| Use when | Custom table layouts, static data | Dynamic data, CRUD views, dashboards |
Migration from Table to DataTable
If you're upgrading from manual Table markup:
<Table density="compact" striped>
<TableHeader>
<TableRow>
<TableHead sortable sorted={sortDir} onSort={handleSort}>
Name
</TableHead>
<TableHead align="right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedData.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.name}</TableCell>
<TableCell align="right">${row.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Plus manual sort state, pagination logic, selection state... */}const columns: ColumnDef<Invoice>[] = [
{ accessorKey: "name", header: "Name" },
{
accessorKey: "amount",
header: "Amount",
cell: ({ getValue }) => `$${getValue()}`,
meta: { align: "right" },
},
];
<DataTable
data={data}
columns={columns}
sorting
pagination
density="compact"
striped
/>The DataTable handles all the state management, sort logic, pagination slicing, and keyboard accessibility that you'd otherwise implement manually.