Data Table
Searchable, sortable and paginated data with controlled state, custom cells, stable row keys and loading/empty states.
Give your AI assistant this page as context.
Connect your library with MCP| Website | Active |
| Mobile app | Planning |
ui/data-tableUsage
Get this component from a saved library export or the connected CLI. The imports below refer to local files installed in your app.
Import styles/components.css, then styles/theme.css once. Wrap your app in ThemeScope from components/ui/utils so typography, focus and overlays share the theme. The installation guide covers runtime dependencies and React/Next.js setup.
import { DataTable } from './components/ui/data-table';
<DataTable caption="Projects" columns={[{key:"name",label:"Name"},{key:"status",label:"Status"}]} rows={[{name:"Website",status:"Active"},{name:"Mobile app",status:"Planning"}]}/>Loading, custom cells and stable rows
Use loading while records are being fetched. emptyMessage and loadingMessage describe these states. getRowKey preserves row identity when sorting and filtering, and column render formats a value without affecting the underlying search and sort data.
<DataTable
caption="Projects"
getRowKey={(row) => row.id}
columns={[
{ key: "name", label: "Project" },
{
key: "status",
label: "Status",
render: (value) => <Badge>{value}</Badge>,
},
]}
rows={[
{ id: "website", name: "Website", status: "Active" },
{ id: "mobile", name: "Mobile app", status: "Planning" },
]}
emptyMessage="No projects yet."
/>query, sort and page, with their change callbacks, let your application control the interface. Filtering, sorting and pagination operate on the supplied rows locally. This is not a virtualized or server-paginated data grid: fetch data in your application and choose a specialized grid for very large datasets.
Design system
This component consumes your semantic colors, spacing, typography and shape tokens. Import the compiled styles and your theme once as described in Export.
Dependencies: Input, Button, Pagination.
Behavior and accessibility
- Provide meaningful labels for interactive controls.
- Connect action callbacks to your application logic.
- Verify keyboard navigation and contrast in your application.
Status: experimental during early access. Sample previews do not send emails, process payments or upload files to a service.
Source and props
This is the exported source, using the same implementation as the editor.
"use client";
/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- The named overflow region needs a tab stop for keyboard scrolling. */
import { useMemo, useState, type ReactNode } from "react";
import { Input } from "./input";
import { Button } from "./button";
import { Pagination } from "./pagination";
export interface DataColumn {
key: string;
label: string;
sortable?: boolean;
render?: (
value: string | number | undefined,
row: Readonly<DataRow>,
) => ReactNode;
}
export type DataRow = Record<string, string | number>;
export interface DataSort {
key: string;
direction: 1 | -1;
}
export function DataTable({
caption,
columns,
rows,
pageSize = 5,
query: controlledQuery,
defaultQuery = "",
onQueryChange,
page: controlledPage,
defaultPage = 1,
onPageChange,
sort: controlledSort,
defaultSort = null,
onSortChange,
getRowKey,
loading = false,
loadingMessage = "Loading records…",
emptyMessage = "No matching records.",
searchPlaceholder = "Search records…",
searchable = true,
}: {
caption: string;
columns: DataColumn[];
rows: DataRow[];
pageSize?: number;
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
sort?: DataSort | null;
defaultSort?: DataSort | null;
onSortChange?: (sort: DataSort | null) => void;
getRowKey?: (row: Readonly<DataRow>, index: number) => string | number;
loading?: boolean;
loadingMessage?: ReactNode;
emptyMessage?: ReactNode;
searchPlaceholder?: string;
searchable?: boolean;
}) {
const [internalQuery, setInternalQuery] = useState(defaultQuery);
const [internalSort, setInternalSort] = useState<DataSort | null>(
defaultSort,
);
const [internalPage, setInternalPage] = useState(defaultPage);
const query = controlledQuery ?? internalQuery;
const sort = controlledSort !== undefined ? controlledSort : internalSort;
const page = controlledPage ?? internalPage;
const setPage = (value: number) => {
if (controlledPage === undefined) setInternalPage(value);
onPageChange?.(value);
};
const filtered = useMemo(() => {
const list = rows.filter((row) =>
columns.some((c) =>
String(row[c.key] ?? "")
.toLowerCase()
.includes(query.toLowerCase()),
),
);
if (sort)
list.sort((a, b) => {
const left = a[sort.key],
right = b[sort.key];
const comparison =
typeof left === "number" && typeof right === "number"
? left - right
: String(left ?? "").localeCompare(String(right ?? ""), undefined, {
numeric: true,
});
return comparison * sort.direction;
});
return list;
}, [rows, columns, query, sort]);
const size = Number.isFinite(pageSize)
? Math.max(1, Math.floor(pageSize))
: 5;
const total = Math.max(1, Math.ceil(filtered.length / size));
const current = Number.isFinite(page)
? Math.max(1, Math.min(Math.floor(page), total))
: 1;
return (
<div className="cr-data-table">
{searchable && (
<Input
aria-label={"Search " + caption}
placeholder={searchPlaceholder}
value={query}
onChange={(event) => {
if (controlledQuery === undefined)
setInternalQuery(event.target.value);
onQueryChange?.(event.target.value);
setPage(1);
}}
/>
)}
<div
className="cr-table-scroll"
role="region"
aria-label={caption}
tabIndex={0}
>
<table className="cr-table" aria-busy={loading || undefined}>
<caption>{caption}</caption>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.key}
scope="col"
aria-sort={
sort?.key === column.key
? sort.direction === 1
? "ascending"
: "descending"
: undefined
}
>
{column.sortable !== false ? (
<Button
variant="ghost"
size="sm"
aria-label={`Sort by ${column.label}, ${sort?.key === column.key && sort.direction === 1 ? "descending" : "ascending"}`}
onClick={() => {
const nextSort: DataSort = {
key: column.key,
direction:
sort?.key === column.key && sort.direction === 1
? -1
: 1,
};
if (controlledSort === undefined)
setInternalSort(nextSort);
onSortChange?.(nextSort);
setPage(1);
}}
>
{column.label}
{sort?.key === column.key ? (
sort.direction === 1 ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 19V5m-7 7 7-7 7 7" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14m-7-7 7 7 7-7" />
</svg>
)
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3v18m-4-4 4 4 4-4M16 21V3m-4 4 4-4 4 4" />
</svg>
)}
</Button>
) : (
column.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{!loading &&
filtered
.slice((current - 1) * size, current * size)
.map((row, index) => (
<tr
key={
getRowKey?.(row, (current - 1) * size + index) ??
row.id ??
(current - 1) * size + index
}
>
{columns.map((column) => (
<td key={column.key}>
{column.render
? column.render(row[column.key], row)
: row[column.key]}
</td>
))}
</tr>
))}
{(loading || !filtered.length) && (
<tr>
<td colSpan={Math.max(1, columns.length)}>
{loading ? loadingMessage : emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="cr-data-table-footer">
<p className="cr-description" role="status">
{loading
? loadingMessage
: filtered.length
? `${(current - 1) * size + 1}–${Math.min(current * size, filtered.length)} of ${filtered.length} records`
: "0 records"}
</p>
<Pagination
label={`${caption} pages`}
page={current}
totalPages={total}
onPageChange={setPage}
disabled={loading}
/>
</div>
</div>
);
}