Unified UI

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-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400

Installation

Install the component via the CLI in one command.

npx @work-rjkashyap/unified-ui add data-table
pnpm dlx @work-rjkashyap/unified-ui add data-table
npx @work-rjkashyap/unified-ui add data-table
bunx @work-rjkashyap/unified-ui add data-table

If 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-ui
pnpm add @work-rjkashyap/unified-ui
yarn add @work-rjkashyap/unified-ui
bun add @work-rjkashyap/unified-ui

Anatomy

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-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
INV-006Frank Castlefrank@example.comViewerInactive$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.

InvoiceNameEmailRoleStatusAmount
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
0 of 12 row(s) selected.
Rows per page
Page 1 of 3
<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.

InvoiceNameEmailRoleStatusAmount
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
0 of 12 row(s) selected.
Rows per page
Page 1 of 3
<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.

InvoiceNameEmailRoleStatusAmount
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
INV-006Frank Castlefrank@example.comViewerInactive$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-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$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.

InvoiceNameEmailRoleStatusAmount
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$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

InvoiceNameEmailRole
INV-001Alice Johnsonalice@example.comAdmin
INV-002Bob Smithbob@example.comEditor
INV-003Charlie Browncharlie@example.comViewer

comfortable (default)

InvoiceNameEmailRole
INV-001Alice Johnsonalice@example.comAdmin
INV-002Bob Smithbob@example.comEditor
INV-003Charlie Browncharlie@example.comViewer
<DataTable data={users} columns={columns} density="compact" />
<DataTable data={users} columns={columns} density="comfortable" />
DensityPaddingBest For
compactReducedData-dense tables, logs, admin dashboards
comfortableDefaultGeneral-purpose, user-facing content

Striped & Bordered

InvoiceNameEmailRole
INV-001Alice Johnsonalice@example.comAdmin
INV-002Bob Smithbob@example.comEditor
INV-003Charlie Browncharlie@example.comViewer
INV-004Diana Princediana@example.comAdmin
INV-005Eve Wilsoneve@example.comEditor
<DataTable data={users} columns={columns} striped bordered hoverable />
PropEffect
stripedAlternating row background using --muted on even rows
borderedVisible borders between all cells using --border-muted
hoverableRow 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.

InvoiceFull NameEmail AddressRoleAccount StatusTotal AmountCreated AtLast Updated
INV-001Alice Johnsonalice@example.comAdminActive$2,5002024-01-152025-06-10
INV-002Bob Smithbob@example.comEditorActive$1,8002024-01-152025-06-10
INV-003Charlie Browncharlie@example.comViewerInactive$9502024-01-152025-06-10
INV-004Diana Princediana@example.comAdminActive$3,2002024-01-152025-06-10
INV-005Eve Wilsoneve@example.comEditorPending$1,4002024-01-152025-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.

InvoiceNameEmailRole
<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.

InvoiceNameEmailRole
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.

Q4 2024 invoice summary — all amounts in USD.
InvoiceNameEmailRoleStatusAmount
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$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.


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-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
0 of 6 row(s) selected.
Rows per page
Page 1 of 2
Showing 5 of 6 rowsLast updated: just now

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>
	}
/>

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>
	)}
/>

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

OptionTypeDefaultDescription
dataTData[]requiredThe data array.
columnsColumnDef<TData>[]requiredColumn definitions.
initialSortingSortingState[]Initial sort state.
initialPaginationPartial<PaginationState>{ pageIndex: 0, pageSize: 10 }Initial pagination.
initialColumnFiltersColumnFiltersState[]Initial column filters.
initialRowSelectionRowSelectionState{}Initial selected rows.
initialColumnVisibilityVisibilityState{}Initial column visibility.
initialGlobalFilterstring""Initial global filter value.

useDataTable Return

PropertyTypeDescription
sortingSortingStateCurrent sort state.
onSortingChangeOnChangeFn<SortingState>Sort state setter (pass to controlled props).
globalFilterstringCurrent global filter value.
onGlobalFilterChangeOnChangeFn<string>Global filter setter.
columnFiltersColumnFiltersStateCurrent column filters.
onColumnFiltersChangeOnChangeFn<ColumnFiltersState>Column filter setter.
paginationPaginationStateCurrent pagination state.
onPaginationChangeOnChangeFn<PaginationState>Pagination setter.
rowSelectionRowSelectionStateCurrent row selection map.
onRowSelectionChangeOnChangeFn<RowSelectionState>Row selection setter.
columnVisibilityVisibilityStateCurrent column visibility map.
onColumnVisibilityChangeOnChangeFn<VisibilityState>Column visibility setter.
tablePropsobjectSpread into <DataTable /> for controlled mode.
reset() => voidReset 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:

User management table
INV-001Alice Johnsonalice@example.comAdminActive$2,500
INV-002Bob Smithbob@example.comEditorActive$1,800
INV-003Charlie Browncharlie@example.comViewerInactive$950
INV-004Diana Princediana@example.comAdminActive$3,200
INV-005Eve Wilsoneve@example.comEditorPending$1,400
0 of 12 row(s) selected.
Rows per page
Page 1 of 3
<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
14February 28, 2026
TASK-2048
enhancementI'll back up the virtual VGA sensor, that should protocol the SDD applicati...
In-Progress
Low
19February 28, 2026
TASK-8557
bugTry to hack the GB transmitter, maybe it will generate the multi-byte firew...
Todo
Low
24February 28, 2026
TASK-4375
enhancementIf we bypass the interface, we can get to the THX panel through the back-...
In-Progress
Low
19February 27, 2026
TASK-3202
documentationI'll program the online HDD array, that should bus the DRAM matrix!
In-Progress
Low
10February 27, 2026
TASK-2496
featureConnecting the pixel won't do anything, we need to connect the multi-byt...
In-Progress
Low
19February 27, 2026
TASK-3513
featureYou can't input the feed without generating the virtual VGA card!
Done
Low
18February 27, 2026
TASK-4272
featureProgramming the protocol won't do anything, we need to calculate the sol...
In-Progress
Medium
11February 27, 2026
TASK-8142
enhancementUse the primary GB capacitor, then you can parse the bluetooth microchip!
In-Progress
Low
22February 27, 2026
TASK-5700
featureI'll compress the mobile GB card, that should alarm the UDP application!
In-Progress
Medium
8February 27, 2026
0 of 14 row(s) selected.
Rows per page
Page 1 of 2

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

PropertyTypeRequiredDescription
columnIdstringYesThe column ID to filter on (must match a ColumnDef accessorKey or id).
titlestringYesDisplay label for the filter button.
iconReactNodeNoIcon rendered before the title in the pill button.
options{ label: string; value: string; icon?: ReactNode }[]NoExplicit 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

PropertyTypeDefaultDescription
align"left" | "center" | "right""left"Text alignment for header and cells.
stickybooleanfalseSticky header for the column.
filterablebooleanfalseShow a per-column filter input under the header.
filterPlaceholderstring"Filter..."Placeholder for the column filter input.
headerClassNamestringAdditional CSS class for the header cell.
cellClassNamestringAdditional 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:

AttributeElementDescription
data-ds-component="data-table"Root wrapperIdentifies the DataTable root
data-ds-component="data-table-toolbar"Toolbar containerToolbar area above the table
data-ds-component="data-table-search"Global filter inputThe search input element
data-ds-component="data-table-column-filter"Column filter inputPer-column filter input under header
data-ds-component="data-table-column-toggle"Columns buttonColumn visibility toggle button
data-ds-component="data-table-column-menu"Columns dropdownColumn visibility dropdown menu
data-ds-component="data-table-pagination"Pagination barPagination controls container
data-ds-row-index="even" / "odd"Table rowRow 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

PropTypeDefaultDescription
dataTData[]requiredArray of data objects.
columnsColumnDef<TData>[]requiredTanStack Table column definitions.
getRowId(row: TData, index: number) => stringRow indexCustom row ID accessor for stable keys.
sortingbooleanfalseEnable client-side sorting.
sortingStateSortingStateControlled sorting state.
onSortingChangeOnChangeFn<SortingState>Controlled sorting callback.
multiSortbooleanfalseEnable multi-column sorting (Shift+click).
filteringbooleanfalseEnable client-side filtering.
showGlobalFilterbooleanfalseShow global search input in toolbar.
globalFilterPlaceholderstring"Search..."Global filter placeholder text.
globalFilterstringControlled global filter value.
onGlobalFilterChangeOnChangeFn<string>Controlled global filter callback.
columnFiltersColumnFiltersStateControlled column filters.
onColumnFiltersChangeOnChangeFn<ColumnFiltersState>Controlled column filters callback.
paginationbooleanfalseEnable client-side pagination.
pageSizenumber10Rows per page.
paginationStatePaginationStateControlled pagination state.
onPaginationChangeOnChangeFn<PaginationState>Controlled pagination callback.
pageSizeOptionsnumber[] | false[10, 20, 30, 50, 100]Page size options. false hides the selector.
rowSelection"single" | "multi" | falsefalseRow selection mode.
rowSelectionStateRowSelectionStateControlled selection state.
onRowSelectionChangeOnChangeFn<RowSelectionState>Controlled selection callback.
onSelectedRowsChange(rows: Row<TData>[]) => voidConvenience callback with full Row objects.
enableRowSelectionboolean | (row: Row) => booleantrueEnable/disable row selection per-row.
columnVisibilitybooleanfalseEnable column visibility toggling.
columnVisibilityStateVisibilityStateControlled visibility state.
onColumnVisibilityChangeOnChangeFn<VisibilityState>Controlled visibility callback.
columnPinningColumnPinningStateControlled column pinning state.
onColumnPinningChangeOnChangeFn<ColumnPinningState>Controlled column pinning callback.
density"compact" | "comfortable""comfortable"Row height density.
stripedbooleanfalseAlternating row backgrounds.
hoverablebooleantrueHighlight rows on hover.
borderedbooleanfalseBorders between cells.
responsivebooleantrueHorizontal scroll wrapper.
loadingbooleanfalseShow loading skeleton.
emptyStateReactNode"No results."Custom empty state content.
captionReactNodeAccessible table caption (<caption>).
showFooterbooleanfalseRender column footer definitions in <tfoot>.
toolbarReactNode | (table) => ReactNodeCustom toolbar content (above table).
footerReactNode | (table) => ReactNodeCustom footer content (below table).
classNamestringRoot wrapper classes.
tableClassNamestring<table> element classes.
wrapperClassNamestringResponsive scroll wrapper classes.
onRowClick(row: Row, event: MouseEvent) => voidRow click handler.
onTableInstance(table: Table) => voidExposes 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-sort attributes (ascending, descending, none). Sort toggle buttons have descriptive aria-label text.
  • 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 caption prop 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 disabled attribute 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.

TokenUsage
--primaryCheckbox accent color, focus ring base
--borderTable borders, input borders, pagination buttons
--border-mutedRow dividers, column filter input borders
--mutedStriped row background, header background, skeleton
--foregroundCell text, button text, header text
--muted-foregroundCaption, pagination info, placeholder text
--backgroundInput backgrounds, button backgrounds
--popoverColumn visibility dropdown background
--popover-foregroundColumn visibility dropdown text
--primary-mutedSelected row background highlight
--focus-ringFocus ring on all interactive elements
--radius-smFilter inputs, checkboxes, skeleton bars
--radius-mdSearch input, pagination buttons, dropdown
--shadow-mdColumn visibility dropdown shadow
--duration-fastHover and focus transition speed
--z-dropdownColumn 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:

AspectTableDataTable
LevelLow-level primitivesHigh-level, feature-rich
DataManual row rendering via JSXDeclarative via data + columns
SortingVisual-only sortDirection propFull client-side sorting logic
FilteringNot includedGlobal + per-column filtering
PaginationNot includedBuilt-in pagination bar
SelectionNot includedSingle/multi row selection
Column visibilityNot includedToggle dropdown
Column pinningNot includedLeft/right pinning
DependenciesNone (pure HTML/CSS)@tanstack/react-table
Bundle size~2 KB~15 KB (+ TanStack Table)
Use whenCustom table layouts, static dataDynamic 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.

On this page