Data
DataTable
Generic, fully typed data table with single or multiple sorting, typed per-column and global filtering, client or server-side pagination with a numbered pager, single/multiple row selection, column visibility, grouped (colspan) header rows, summary/footer rows, CSV export, per-column menus with pinning, windowed rendering, loading skeletons/overlay and an empty state.
Import
import { DataTable, dataTableToCsv } from "@zephora/react";Examples
Sorting and pagination
`DataTable` is generic over the row type — pass it explicitly for full type-safety in `columns`, `rowKey` and `cell` renderers. Click a header to cycle asc → desc → off.
| Ada Lovelace | Engineer | 36 |
| Alan Turing | Mathematician | 41 |
| Barbara Liskov | Professor | 84 |
| Donald Knuth | Professor | 87 |
interface Person {
id: string;
name: string;
role: string;
age: number;
}
const people: Person[] = [
{ id: "1", name: "Ada Lovelace", role: "Engineer", age: 36 },
{ id: "2", name: "Grace Hopper", role: "Admiral", age: 85 },
{ id: "3", name: "Alan Turing", role: "Mathematician", age: 41 },
// …
];
const columns: Array<DataTableColumn<Person>> = [
{ key: "name", header: "Name", sortable: true },
{ key: "role", header: "Role", sortable: true },
{ key: "age", header: "Age", sortable: true, align: "end", width: 80 },
];
<DataTable<Person>
columns={columns}
data={people}
rowKey={(row) => row.id}
defaultSort={{ key: "name", direction: "asc" }}
pagination={{ pageSize: 4 }}
/>Row selection and global filter
Selection is fully controlled; the header checkbox selects every row that matches the current filters.
| Ada Lovelace | Engineer | 36 | |
| Grace Hopper | Admiral | 85 | |
| Alan Turing | Mathematician | 41 | |
| Katherine Johnson | Physicist | 101 | |
| Margaret Hamilton | Engineer | 88 | |
| Edsger Dijkstra | Professor | 72 | |
| Barbara Liskov | Professor | 84 | |
| Donald Knuth | Professor | 87 |
const [selected, setSelected] = React.useState<string[]>([]);
const [query, setQuery] = React.useState("");
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />
<DataTable<Person>
columns={columns}
data={people}
rowKey={(row) => row.id}
globalFilter={query}
selection={{ selected, onSelectionChange: setSelected }}
striped
size="sm"
/>Loading and empty states
| No people match your filters. | ||
<DataTable<Person> columns={columns} data={[]} rowKey={(row) => row.id} loading />
<DataTable<Person>
columns={columns}
data={[]}
rowKey={(row) => row.id}
empty="No people match your filters."
/>Server-side (manual) mode
`manual` renders `data` exactly as given — no client-side sorting, filtering or page slicing — and only reports state changes through the callbacks. Pair it with `pagination.page` + `pagination.total` (and here a simulated server that sorts and slices).
| Ada Lovelace | Engineer | 36 |
| Grace Hopper | Admiral | 85 |
| Alan Turing | Mathematician | 41 |
const PAGE_SIZE_OPTIONS = [3, 5];
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(3);
const [sort, setSort] = React.useState<DataTableSort | null>(null);
// Pretend this is the server: sort + slice the full dataset.
const rows = React.useMemo(() => {
const all = [...people];
if (sort) {
const dir = sort.direction === "asc" ? 1 : -1;
all.sort((a, b) => {
const av = a[sort.key as keyof Person];
const bv = b[sort.key as keyof Person];
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
}
return all.slice(page * pageSize, page * pageSize + pageSize);
}, [page, pageSize, sort]);
<DataTable<Person>
manual
columns={columns}
data={rows}
rowKey={(row) => row.id}
sort={sort}
onSortChange={(next) => { setSort(next); setPage(0); }}
pagination={{
pageSize,
page,
total: people.length,
onPageChange: setPage,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onPageSizeChange: (size) => { setPageSize(size); setPage(0); },
}}
/>Expandable rows
`expandable.render` adds a leading toggle column and a full-width detail row; `onRowClick` makes whole rows interactive (click, plus Enter/Space when focused).
| Ada Lovelace | Engineer | 36 | |
Ada Lovelace — Engineer, age 36. | |||
| Grace Hopper | Admiral | 85 | |
| Alan Turing | Mathematician | 41 | |
| Katherine Johnson | Physicist | 101 | |
const [last, setLast] = React.useState("");
<DataTable<Person>
columns={columns}
data={people.slice(0, 4)}
rowKey={(row) => row.id}
expandable={{
render: (row) => (
<p style={{ margin: 0 }}>
{row.name} — {row.role}, age {row.age}.
</p>
),
defaultExpandedKeys: ["1"],
}}
onRowClick={(row) => setLast(row.name)}
/>
<p>Last clicked: {last || "none"}</p>Typed filters
`filterType` picks the filter editor and operator set per column: `number` gets =, ≠, >, ≥, <, ≤ and between, `select` a multi-select fed by `filterOptions` (derived from unique column values, max 50, when omitted), `date` on/before/after/between. Filter values are a plain string (classic contains) or `{ op, value }`.
| Name | Role | Age |
|---|---|---|
| Grace Hopper | Admiral | 85 |
| Alan Turing | Mathematician | 41 |
| Margaret Hamilton | Engineer | 88 |
| Edsger Dijkstra | Professor | 72 |
| Barbara Liskov | Professor | 84 |
| Donald Knuth | Professor | 87 |
const columns: Array<DataTableColumn<Person>> = [
{ key: "name", header: "Name", filterable: true },
{
key: "role",
header: "Role",
filterable: true,
filterType: "select",
filterOptions: [
{ label: "Engineer", value: "Engineer" },
{ label: "Professor", value: "Professor" },
{ label: "Mathematician", value: "Mathematician" },
],
},
{ key: "age", header: "Age", filterable: true, filterType: "number", align: "end", width: 170 },
];
<DataTable<Person>
columns={columns}
data={people}
rowKey={(row) => row.id}
defaultFilters={{ age: { op: "between", value: [40, 90] } }}
/>Selection modes
`selection.mode="single"` renders radios and drops the select-all header. Rows failing `isRowSelectable` render a disabled input and are excluded from select-all. In multiple mode, Shift+Click selects the range since the last toggled row. `showColumnMenu` adds a ⋮ menu to each data column header with sort, hide and pin actions.
| Ada Lovelace | Engineer | 36 | |
| Grace Hopper | Admiral | 85 | |
| Alan Turing | Mathematician | 41 | |
| Katherine Johnson | Physicist | 101 | |
| Margaret Hamilton | Engineer | 88 |
const [choice, setChoice] = React.useState<string[]>([]);
<DataTable<Person>
columns={columns}
data={people.slice(0, 5)}
rowKey={(row) => row.id}
selection={{
mode: "single",
selected: choice,
onSelectionChange: setChoice,
isRowSelectable: (row) => row.age < 100,
}}
showColumnMenu
size="sm"
/>
<p>Selected: {choice.join(", ") || "none"}</p>Header groups & summary
`headerGroups` adds a colspan header row above the main header — each group spans its visible member columns; reordering splits non-contiguous members into runs sharing the same label. `column.footer` renders a `<tfoot>` summary row; the function form receives the PROCESSED rows (filtered and sorted, before the pagination slice), so aggregates cover every matching row. `stickyFooter` keeps it pinned to the bottom edge while scrolling.
| Person | Stats | |
|---|---|---|
| Ada Lovelace | Engineer | 36 |
| Grace Hopper | Admiral | 85 |
| Alan Turing | Mathematician | 41 |
| Katherine Johnson | Physicist | 101 |
| Margaret Hamilton | Engineer | 88 |
| Edsger Dijkstra | Professor | 72 |
| Barbara Liskov | Professor | 84 |
| Donald Knuth | Professor | 87 |
| Average age | 74.3 | |
const columns: Array<DataTableColumn<Person>> = [
{ key: "name", header: "Name", sortable: true, footer: "Average age" },
{ key: "role", header: "Role", sortable: true },
{
key: "age",
header: "Age",
sortable: true,
align: "end",
width: 80,
footer: (rows) =>
rows.length === 0
? "—"
: (rows.reduce((sum, row) => sum + row.age, 0) / rows.length).toFixed(1),
},
];
const headerGroups: DataTableHeaderGroup[] = [
{ header: "Person", columns: ["name", "role"] },
{ header: "Stats", columns: ["age"] },
];
<DataTable<Person>
columns={columns}
data={people}
rowKey={(row) => row.id}
headerGroups={headerGroups}
stickyHeader
stickyFooter
style={{ maxBlockSize: 260 }}
/>Exports & the imperative ref API
Attach a `ref` to get a `DataTableHandle` with programmatic methods — everything stays optional, nothing changes without the ref. `exportCsv` mirrors the built-in button; `exportXlsx` writes a real dependency-free `.xlsx` workbook (styled header, frozen row, auto-filter, numeric cells); `exportPdf` opens a print-optimized document to save as PDF (full Unicode). All three export the CURRENT view: filtered + sorted rows, visible columns. `getProcessedRows`/`getSelectedRows` expose the same data for custom pipelines.
| Ada Lovelace | Engineer | 36 |
| Grace Hopper | Admiral | 85 |
| Alan Turing | Mathematician | 41 |
| Katherine Johnson | Physicist | 101 |
| Margaret Hamilton | Engineer | 88 |
const tableRef = React.useRef<DataTableHandle<Person>>(null);
<Button onClick={() => tableRef.current?.exportCsv({ filename: "people.csv" })}>CSV</Button>
<Button onClick={() => tableRef.current?.exportXlsx({
filename: "people.xlsx",
sheetName: "People",
headerFill: "#7c3aed",
orientation: "landscape",
})}>Excel</Button>
<Button onClick={() => tableRef.current?.exportPdf({
title: "People report",
orientation: "landscape",
})}>PDF</Button>
<DataTable<Person> ref={tableRef} columns={columns} data={people} rowKey={(r) => r.id} />API
DataTable<T> props
| Prop | Type | Default | Description |
|---|---|---|---|
columns * | Array<DataTableColumn<T>> | — | Column definitions (see the table below). |
data * | T[] | — | Row objects. |
rowKey * | (row: T) => string | — | Stable unique key per row. |
sort / defaultSort / onSortChange | DataTableSort | null | — | Controlled / uncontrolled sort state: `{ key, direction: "asc" | "desc" }`. |
sortMode | "single" | "multiple" | "single" | "single" keeps the classic one-column cycle; "multiple" builds an ordered sort list — clicking a column appends it asc, then cycles desc, then removes it. |
multiSort / defaultMultiSort / onMultiSortChange | DataTableSort[] | — | Controlled / uncontrolled multi-sort state (used when sortMode="multiple"). |
filters / defaultFilters / onFiltersChange | Record<string, DataTableFilterValue> | — | Controlled / uncontrolled per-column filter values. A plain string keeps the classic case-insensitive contains semantics (back-compat); the object form `{ op, value? }` selects an explicit operator matching the column's `filterType` (value: `string | number | [number, number] | string[]`). |
DataTableFilterOp | string union | — | Operators available per filter type — text: "contains" | "equals" | "startsWith" | "endsWith" | "notContains" | "isEmpty"; number: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "between"; select: "in"; date: "on" | "before" | "after" | "between". |
globalFilter | string | — | Matches rows where any column contains the query. |
pagination | DataTablePagination | — | Enables the pager: { pageSize, page? (controlled, 0-based), total? (server-side row count), onPageChange?, pageSizeOptions? (renders a page-size select labeled "Rows per page"), onPageSizeChange?, allowAll? (appends a show-everything option), allLabel? (its label, default "All") }. The pager renders numbered page buttons with boundary/sibling ellipses plus a "from–to of total" range readout. Reset policy: while the page is uncontrolled, any filter, global-filter or sort change resets it to 0 (firing onPageChange), and picking a page size also resets to 0; a controlled page is never written to — only onPageChange fires. |
stateKey | string | — | Persists the user's view state — column order, visibility, pinning, resized widths, page size and sort — to storage under this key and restores it on mount. Uncontrolled aspects only; controlled props stay the source of truth. Clear via the ref handle's clearPersistedState(). SSR note: restoration happens on the client, so persisted tables should be client-only or accept default-state first paint. |
stateStorage | "local" | "session" | "local" | Storage backing stateKey. |
selection | DataTableSelection<T> | — | { mode? ("multiple" default; "single" renders radios and no select-all header), selected? / defaultSelected? / onSelectionChange? (controlled or uncontrolled row keys), isRowSelectable? (rows returning false render a disabled input and are excluded from select-all) }. In multiple mode, Shift+Click selects the visible-row range between the last toggled row and the target, respecting isRowSelectable. |
virtualize | { height: number; rowHeight: number; overscan?: number } | — | Simple windowing: renders only the visible rows plus spacers. `overscan` (default 4) renders extra rows above/below the visible window. Not supported together with `expandable` — when both are provided the table warns in the console and ignores `expandable`. |
manual | boolean | false | Server-side mode — shorthand that turns on all three manual* flags: data renders as-is (no client-side sorting, filtering or page slicing) and state changes are only reported through the sort/filter/page callbacks. Combine with pagination.total + pagination.page. |
manualSorting / manualFiltering / manualPagination | boolean | manual | Granular server-side flags — each disables only its own client-side step (sorting, filtering or page slicing). Each defaults to `manual`, so any one can be overridden individually. |
onRowClick | (row: T, event: MouseEvent | KeyboardEvent) => void | — | Makes rows interactive: click, plus Enter/Space when the row is focused. |
expandable | DataTableExpandable<T> | — | Row expansion: { render(row), expandedKeys?, defaultExpandedKeys?, onExpandedChange?, rowExpandable? } — adds a leading toggle column and a full-width detail row. Rows where rowExpandable returns false render an empty toggle cell (no button) and never show a detail row. Ignored (with a console warning) while `virtualize` is set. |
resizableColumns | boolean | false | Adds pointer + keyboard resize handles to header edges. |
reorderableColumns | boolean | false | Enables header drag-and-drop column reordering (HTML5 draggable). Pass columnOrder for programmatic/keyboard-free control. |
columnOrder / defaultColumnOrder / onColumnOrderChange | string[] | — | Controlled / uncontrolled column order (list of column keys). |
columnVisibility / defaultColumnVisibility / onColumnVisibilityChange | Record<string, boolean> | — | Controlled / uncontrolled column visibility. `false` hides a column; missing keys are visible. Hidden columns are excluded from render/pin/order math but stay in state (and their filters keep applying). |
showColumnVisibilityMenu | boolean | false | Renders a toolbar button opening a checkbox menu to toggle column visibility. |
headerGroups | DataTableHeaderGroup[] | — | Extra header row above the main header: each `{ header, columns }` group spans its visible member columns with `<th colSpan scope="colgroup">`. Groups render in the order their first member appears; members made non-contiguous by reordering are split into contiguous runs sharing the same label. Ungrouped columns (and the select/expand helpers) get empty spanning cells. Compatible with `stickyHeader` — the group row sticks above the main header and filter rows. |
stickyFooter | boolean | false | Keeps the summary/footer row (`column.footer`) stuck to the bottom edge while scrolling. |
showExportButton | boolean | false | Renders a toolbar button that downloads the PROCESSED rows (filtered and sorted, ignoring pagination) as CSV — visible columns only, in current order. Serialization follows the `dataTableToCsv` rules below. |
exportOptions | DataTableExportOptions | — | Options for the export button: { filename? (default "table.csv"), delimiter? (default ","), includeHeaders? (default true), bom? (default true) }. |
showColumnMenu | boolean | false | Adds a per-column ⋮ menu button to each data column header with sort, hide and pin actions. Items appear per column capability: sort entries only for sortable columns, unpin only while the column is (effectively) pinned. |
columnPinning / defaultColumnPinning / onColumnPinningChange | Record<string, "start" | "end" | false> | — | Controlled / uncontrolled per-column pin state overriding `column.pin`; `false` explicitly unpins. Effective pin = `columnPinning[key] ?? column.pin`. |
loading | boolean | false | Loading state (sets aria-busy). With no rows, skeleton rows render (as many as the page size, 3 without pagination). With rows present, the current rows stay visible, dimmed under a spinner overlay (role="status"). |
empty | ReactNode | "No data" | Content shown when there are no rows. |
getRowClassName | (row: T, index: number) => string | undefined | — | Class applied to each body `<tr>`. |
getRowStyle | (row: T, index: number) => CSSProperties | undefined | — | Inline style applied to each body `<tr>`. |
size | "sm" | "md" | "lg" | "md" | Density scale. |
striped | boolean | false | Alternating row backgrounds. |
stickyHeader | boolean | false | Keeps the header row pinned while scrolling. |
unstyled | boolean | false | Headless mode — skips Zephora styling. |
DataTableColumn<T> props
| Prop | Type | Default | Description |
|---|---|---|---|
key * | string | — | Unique column id; also the default row property read for values. |
header * | ReactNode | — | Header cell content. |
cell | (row: T, index: number) => ReactNode | — | Custom cell renderer. |
accessor | (row: T) => unknown | — | Value accessor used for sorting/filtering. Defaults to `row[key]`. |
sortable | boolean | — | Renders the header as a sort button (asc → desc → off). |
filterable | boolean | — | Adds a per-column filter input in a second header row. |
width | number | string | — | Fixed column inline-size. |
align | "start" | "center" | "end" | — | Cell text alignment. |
pin | "start" | "end" | — | Pins the column to the start or end edge (sticky positioning). |
sortFn | (a: T, b: T) => number | — | Custom comparator (receives whole rows). Overrides the built-in type-aware comparison in both single and multiple sort modes; return a negative/zero/positive number like `Array.prototype.sort`. |
filterType | "text" | "number" | "select" | "date" | "text" | Filter editor rendered in the filter row and the value semantics used when filtering. |
filterOptions | Array<{ label: string; value: string }> | — | Options for filterType="select"; derived from unique column values (max 50) when omitted. |
headerText | string | — | Plain-text header fallback used for accessible labels and the column visibility menu when `header` is not a string. |
headerTooltip | string | — | Native tooltip (title attribute) shown when hovering the header cell. |
footer | ReactNode | ((rows: T[]) => ReactNode) | — | Summary/footer cell content. The function form receives the CURRENTLY PROCESSED rows — filtered and sorted, but BEFORE the pagination slice — so aggregates cover every matching row, not just the visible page. A `<tfoot>` row renders when any visible column defines `footer`. |
dataTableToCsv(columns, rows, options?) props
| Prop | Type | Default | Description |
|---|---|---|---|
columns * | Array<DataTableColumn<T>> | — | Columns to serialize. Cell text comes from `column.accessor` (else `row[key]`); non-string headers fall back to `headerText ?? key`. |
rows * | T[] | — | Rows to serialize. The helper is a pure standalone export — usable without the component. |
options | DataTableCsvOptions | — | { delimiter? (default ","), includeHeaders? (default true), bom? (default true — prefixes the UTF-8 BOM so Excel detects the encoding) }. |
returns | string | — | RFC 4180 CSV: fields containing quotes, the delimiter or newlines are quoted with doubled quotes; lines are joined with CRLF. |
DataTableHandle<T> (ref) props
| Prop | Type | Default | Description |
|---|---|---|---|
exportCsv | (options?: DataTableExportOptions) => void | — | Downloads the current view (filtered + sorted rows, visible columns) as CSV. Merges over the table's `exportOptions`. |
exportXlsx | (options?: DataTableXlsxExportOptions) => Promise<void> | — | Builds and downloads a real .xlsx workbook with zero dependencies (stored-ZIP + SpreadsheetML). Options: filename, sheetName, headerFill/headerColor/cellColor (#rrggbb), orientation, columnWidths (chars, per key), freezeHeader (default true), autoFilter (default true). Numbers export as numeric cells; the writer loads lazily on first call. |
exportPdf | (options?: DataTablePdfOptions) => Promise<void> | — | Renders a print-optimized document (repeating header, zebra rows, page orientation) into a hidden iframe and opens the print dialog to save as PDF. Full Unicode — no jsPDF/font subsetting. Options: title, orientation, fontSize, fontFamily, headerFill/headerColor/cellColor, striped, columnWidths (CSS values). |
getProcessedRows | () => T[] | — | Rows after filtering and sorting, before the pagination slice — the exact set exports operate on. |
getSelectedRows | () => T[] | — | Currently selected rows (empty when selection is disabled). |
clearPersistedState | () => void | — | Removes the view state saved under stateKey (no-op without one). |
element | HTMLDivElement | null | — | The table's root element for measurements or scrolling. |
Keyboard
| Key | Action |
|---|---|
Tab | Moves through sort buttons, filter inputs, checkboxes and the pager. |
Enter / Space | Activates the focused sort button (cycles asc → desc → off). |
Space | Toggles the focused row / select-all checkbox. |
Shift + Click | On a row checkbox (multiple selection mode): selects the visible-row range between the last toggled row and the clicked row, respecting isRowSelectable. |