DataTable
Column-driven table composing SortHeader, SkeletonRows, Pagination and an empty state.
import { DataTable, DataTableColumn }from "@/components/ui"
Specification
Renders a column-driven data table that composes SortHeader, SkeletonRows, Pagination and an empty state, with per-row status styling.
Props (14)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| columns* | DataTableColumn<T>[] | — | Column definitions (key, header, cell renderer and optional sortable/align/numeric/width/className). |
| rows* | T[] | — | The row data to render. |
| rowKey* | (row: T) => string | — | Returns a stable unique key for each row. |
| loading | boolean | — | Shows skeleton rows and a top progress bar while data loads. |
| sort | { key: string; dir: "asc" | "desc" | "none" } | — | Current sort column and direction. |
| onSortChange | (key: string) => void | — | Called with a column key when a sortable header is clicked. |
| empty | { title: string; body: string } | — | Custom title/body for the empty state when there are no rows. |
| pagination | PaginationProps | — | When provided, renders a Pagination footer with these props. |
| onRowClick | (row: T) => void | — | Makes rows clickable; called with the clicked row. |
| rowStatus | (row: T) => "default" | "selected" | "error" | "pending" | — | Returns a per-row status that drives its background and accent bar. |
| zebra | boolean | — | Applies alternating row backgrounds for readability. |
| title | string | — | Optional table title shown in the header bar. |
| subtitle | string | — | Optional subtitle (e.g. a count) shown under the title. |
| caption | string | — | Screen-reader-only <caption> describing the table. |
Sortable columns
Team members
3 of 3
| Role | Status | Balance | |
|---|---|---|---|
| Amina Wanjiru | Owner | Active | KES 128,400 |
| Brian Otieno | Approver | Active | KES 54,200 |
| Dennis Ndung'u | Preparer | Invited | KES 0 |
Full source
The complete component, read from
src/components/ui/data-table.tsx.data-table.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { SortHeader } from "./sort-header";
import { SkeletonRows } from "./skeleton-rows";
import { Pagination, type PaginationProps } from "./pagination";
import { Blank } from "./blank";
type SortState = "asc" | "desc" | "none";
export type RowStatus = "default" | "selected" | "error" | "pending";
export interface DataTableColumn<T> {
key: string;
header: string;
cell: (row: T) => ReactNode;
sortable?: boolean;
align?: "left" | "center" | "right";
numeric?: boolean;
width?: string;
className?: string;
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
loading?: boolean;
sort?: { key: string; dir: SortState };
onSortChange?: (key: string) => void;
empty?: { title: string; body: string };
pagination?: PaginationProps;
onRowClick?: (row: T) => void;
rowStatus?: (row: T) => RowStatus;
zebra?: boolean;
title?: string;
subtitle?: string;
caption?: string;
}
const ALIGN: Record<NonNullable<DataTableColumn<unknown>["align"]>, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
export function DataTable<T>({
columns,
rows,
rowKey,
loading,
sort,
onSortChange,
empty,
pagination,
onRowClick,
rowStatus,
zebra,
title,
subtitle,
caption,
}: DataTableProps<T>) {
const showEmpty = !loading && rows.length === 0;
return (
<div className="relative overflow-hidden rounded-xl border border-border bg-surface">
{}
{loading && (
<div className="absolute inset-x-0 top-0 z-10 h-0.5 animate-pulse bg-accent" aria-hidden />
)}
{}
{(title || subtitle) && (
<div className="flex min-h-14 flex-wrap items-center gap-3 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
{title && <h2 className="truncate text-sm font-semibold text-text">{title}</h2>}
{subtitle && <p className="mt-0.5 truncate text-xs tabular-nums text-text-secondary">{subtitle}</p>}
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
{caption && <caption className="sr-only">{caption}</caption>}
<thead>
<tr className="border-b border-border">
{columns.map((col) => {
const align = col.numeric ? "right" : col.align ?? "left";
const style = col.width ? { width: col.width } : undefined;
return col.sortable ? (
<SortHeader
key={col.key}
label={col.header}
state={sort?.key === col.key ? sort.dir : "none"}
onClick={() => onSortChange?.(col.key)}
style={style}
className={col.className}
/>
) : (
<th
key={col.key}
scope="col"
style={style}
className={cn(
"px-4 py-2.5 text-xs font-medium uppercase tracking-wider text-text-secondary",
ALIGN[align],
col.className
)}
>
{col.header}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? (
<SkeletonRows n={6} colCount={Math.max(1, columns.length - 1)} />
) : (
rows.map((row, i) => {
const status = rowStatus?.(row) ?? "default";
return (
<tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
aria-selected={status === "selected" || undefined}
className={cn(
"group relative border-b border-border transition-colors last:border-0",
zebra && status === "default" && i % 2 === 1 && "bg-bg",
status === "default" && onRowClick && "hover:bg-bg-hover",
status === "selected" && "bg-accent-light",
status === "error" && "bg-error-light",
status === "pending" && "opacity-60",
onRowClick && "cursor-pointer",
(status === "selected" || status === "error") &&
"before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:content-['']",
status === "selected" && "before:bg-accent",
status === "error" && "before:bg-error"
)}
>
{columns.map((col) => {
const align = col.numeric ? "right" : col.align ?? "left";
return (
<td
key={col.key}
className={cn(
"px-4 py-3 text-text",
ALIGN[align],
col.numeric && "tabular-nums",
col.className
)}
>
{col.cell(row)}
</td>
);
})}
</tr>
);
})
)}
</tbody>
</table>
</div>
{showEmpty && (
<Blank title={empty?.title ?? "No results"} body={empty?.body ?? "There's nothing to show yet."} />
)}
{pagination && !showEmpty && <Pagination {...pagination} />}
</div>
);
}Dependencies
To use DataTable you also need the following. Copy these into your project alongside it.
Related components
Recommended folder structure
project structure
src/components/ui/ ├─ blank.tsx ├─ data-table.tsx ├─ pagination.tsx ├─ skeleton-rows.tsx └─ sort-header.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}