Zephora UI

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, inline cell editing, multi-level row grouping with aggregations, 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 LovelaceEngineer36
Alan TuringMathematician41
Barbara LiskovProfessor84
Donald KnuthProfessor87

Row selection and global filter

Selection is fully controlled; the header checkbox selects every row that matches the current filters.

Ada LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Margaret HamiltonEngineer88
Edsger DijkstraProfessor72
Barbara LiskovProfessor84
Donald KnuthProfessor87
0 row(s) selected

Loading and empty states

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 LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41

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

Expand row
Ada LovelaceEngineer36

Ada LovelaceEngineer, age 36.

Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Last clicked: none

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 }`.

NameRoleAge
Grace HopperAdmiral85
Alan TuringMathematician41
Margaret HamiltonEngineer88
Edsger DijkstraProfessor72
Barbara LiskovProfessor84
Donald KnuthProfessor87

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.

Select row
Ada LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Margaret HamiltonEngineer88
Selected: none

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.

PersonStats
Ada LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Margaret HamiltonEngineer88
Edsger DijkstraProfessor72
Barbara LiskovProfessor84
Donald KnuthProfessor87
Average age74.3

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 LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Margaret HamiltonEngineer88

Inline editing

`editing` + `column.editable` turn cells into inline editors (double-click by default, or Enter/F2 while focused). The editor is picked from `filterType` (number/date), a select when `filterOptions` exist, else a text input — or pass `column.editor` for a custom one. Commit order: `column.validate` (a string keeps the editor open with the error) → `onCellCommit`; the table never mutates `data`, so apply the returned row to your own state. Enter commits, Escape cancels, Tab commits then moves along the row.

NameRoleAge
Ada LovelaceEngineer36
Grace HopperAdmiral85
Alan TuringMathematician41
Katherine JohnsonPhysicist101
Margaret HamiltonEngineer88
Double-click a cell to edit — Enter commits, Escape cancels, Tab commits and moves along the row.

Row grouping

`grouping.groupBy` buckets the processed (filtered + sorted) rows under expandable group-header rows — one nesting level per key. `aggregations` shows per-column summaries (sum/count/min/max/avg or a custom function) inline on each header, computed over the group's FULL row set. Every group starts expanded by default; expansion is controllable via `expandedGroups`. Not supported together with `virtualize` or `expandable` (console warning; `grouping` is ignored).

Engineer(2)Avg: 62
Ada LovelaceEngineer36
Margaret HamiltonEngineer88
Admiral(1)Avg: 85
Grace HopperAdmiral85
Mathematician(1)Avg: 41
Alan TuringMathematician41
Physicist(1)Avg: 101
Katherine JohnsonPhysicist101
Professor(3)Avg: 81
Edsger DijkstraProfessor72
Barbara LiskovProfessor84
Donald KnuthProfessor87

API

DataTable<T> props

PropTypeDefaultDescription
columns *Array<DataTableColumn<T>>Column definitions (see the table below).
data *T[]Row objects.
rowKey *(row: T) => stringStable unique key per row.
sort / defaultSort / onSortChangeDataTableSort | nullControlled / 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 / onMultiSortChangeDataTableSort[]Controlled / uncontrolled multi-sort state (used when sortMode="multiple").
filters / defaultFilters / onFiltersChangeRecord<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[]`).
DataTableFilterOpstring unionOperators 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".
globalFilterstringMatches rows where any column contains the query.
paginationDataTablePaginationEnables 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.
stateKeystringPersists 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.
selectionDataTableSelection<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.
editingDataTableEditing<T>Inline cell editing (v1: mode "cell"; see the table below). Editable cells open an editor on the configured trigger (double-click by default) or Enter/F2 while focused. The table never mutates `data` — apply the row returned by `onCellCommit` to your own state.
groupingDataTableGrouping<T>Row grouping (see the table below): groups the processed rows by one or more column keys into expandable group-header rows, with optional per-column aggregations shown inline. Pagination counts LEAF rows (headers are injected per page and don't consume page size). Not supported together with `virtualize` or `expandable` (console warning; `grouping` is ignored).
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`.
manualbooleanfalseServer-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 / manualPaginationbooleanmanualGranular 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) => voidMakes rows interactive: click, plus Enter/Space when the row is focused.
expandableDataTableExpandable<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.
resizableColumnsbooleanfalseAdds pointer + keyboard resize handles to header edges.
reorderableColumnsbooleanfalseEnables header drag-and-drop column reordering (HTML5 draggable). Pass columnOrder for programmatic/keyboard-free control.
columnOrder / defaultColumnOrder / onColumnOrderChangestring[]Controlled / uncontrolled column order (list of column keys).
columnVisibility / defaultColumnVisibility / onColumnVisibilityChangeRecord<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).
showColumnVisibilityMenubooleanfalseRenders a toolbar button opening a checkbox menu to toggle column visibility.
headerGroupsDataTableHeaderGroup[]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.
stickyFooterbooleanfalseKeeps the summary/footer row (`column.footer`) stuck to the bottom edge while scrolling.
showExportButtonbooleanfalseRenders 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.
exportOptionsDataTableExportOptionsOptions for the export button: { filename? (default "table.csv"), delimiter? (default ","), includeHeaders? (default true), bom? (default true) }.
showColumnMenubooleanfalseAdds 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 / onColumnPinningChangeRecord<string, "start" | "end" | false>Controlled / uncontrolled per-column pin state overriding `column.pin`; `false` explicitly unpins. Effective pin = `columnPinning[key] ?? column.pin`.
loadingbooleanfalseLoading 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").
emptyReactNode"No data"Content shown when there are no rows.
getRowClassName(row: T, index: number) => string | undefinedClass applied to each body `<tr>`.
getRowStyle(row: T, index: number) => CSSProperties | undefinedInline style applied to each body `<tr>`.
size"sm" | "md" | "lg""md"Density scale.
stripedbooleanfalseAlternating row backgrounds.
stickyHeaderbooleanfalseKeeps the header row pinned while scrolling.
unstyledbooleanfalseHeadless mode — skips Zephora styling.

DataTableColumn<T> props

PropTypeDefaultDescription
key *stringUnique column id; also the default row property read for values.
header *ReactNodeHeader cell content.
cell(row: T, index: number) => ReactNodeCustom cell renderer.
accessor(row: T) => unknownValue accessor used for sorting/filtering. Defaults to `row[key]`.
sortablebooleanRenders the header as a sort button (asc → desc → off).
filterablebooleanAdds a per-column filter input in a second header row.
widthnumber | stringFixed 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) => numberCustom 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.
filterOptionsArray<{ label: string; value: string }>Options for filterType="select"; derived from unique column values (max 50) when omitted.
headerTextstringPlain-text header fallback used for accessible labels and the column visibility menu when `header` is not a string.
headerTooltipstringNative tooltip (title attribute) shown when hovering the header cell.
footerReactNode | ((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`.
editableboolean | ((row: T) => boolean)Enables inline editing for this column's cells (with the table-level `editing` prop). The function form decides per row.
editor(props: DataTableEditorProps<T>) => ReactNodeCustom cell editor. Receives { row, column, value, onChange (updates the draft), commit, cancel, busy, error }. Defaults to an input picked from `filterType` (number/date), a select when `filterOptions` exist, else a text input.
validate(value: unknown, row: T) => string | null | undefinedDraft validation run before `onCellCommit`. Return an error message (string) to reject the commit and keep the editor open.

DataTableEditing<T> props

PropTypeDefaultDescription
mode"cell""cell"v1 supports cell editing only.
editingCell / defaultEditingCell / onEditingCellChange{ rowKey: string; columnKey: string } | nullControlled / uncontrolled address of the cell being edited. Uncontrolled when omitted.
onCellCommit *(args: { row: T; columnKey: string; value: unknown; oldValue: unknown }) => T | Promise<T> | false | Promise<false>Called when a draft is committed (Enter/Tab/blur). Return the updated row (sync or async) to accept — the table never mutates `data`; apply the returned row to your own state. Return `false` (or reject) to stay in edit mode with an error shown; a promise result shows a busy state until it settles.
trigger"doubleClick" | "click""doubleClick"Pointer gesture that opens the editor.

DataTableGrouping<T> props

PropTypeDefaultDescription
groupBy *string[]Ordered column keys to group by — one nesting level per entry. Groups appear in first-occurrence order of the (already sorted) rows, so sorting orders both the groups and the rows inside each group.
aggregationsRecord<string, DataTableAggregation<T>>Per-column summary shown inline on the group-header row (`columnKey → aggregation`). Aggregates cover the group's FULL row set, not just the rows on the visible page.
expandedGroupsstring[]Controlled expanded group keys. Keys are deterministic paths including the parent chain: `"role=Admin"`, `"role=Admin/dept=IT"`. Uncontrolled when omitted.
defaultExpandedGroupsstring[] | "all""all"Initial expanded groups (uncontrolled). `"all"` starts every group expanded.
onExpandedGroupsChange(keys: string[]) => voidCalled with the expanded path keys whenever a group is toggled.
renderGroupRow(group: DataTableGroupRow<T>) => ReactNodeReplaces the default group-header content (value + row count + aggregations). The expand/collapse toggle stays — the table owns it so `aria-expanded` and keyboard access keep working.

DataTableAggregation<T> props

PropTypeDefaultDescription
"sum" | "count" | "min" | "max" | "avg"built-inBuilt-in aggregation over a column's values within one group. Non-numeric/null/empty values are skipped (`count` counts rows); an empty numeric pool yields no summary chip.
(rows: T[]) => ReactNodecustom functionCustom aggregation receiving the group's full row set; its return value renders as-is (no built-in label prefix).

DataTableGroupRow<T> props

PropTypeDefaultDescription
keystringDeterministic path key including the parent chain: `"role=Admin"`, `"role=Admin/dept=IT"`.
valueunknownThe grouped column's value for this group.
rowsT[]Every leaf row under this group (across all pages).
depthnumber0-based nesting level (index into `groupBy`).

dataTableToCsv(columns, rows, options?) props

PropTypeDefaultDescription
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.
optionsDataTableCsvOptions{ delimiter? (default ","), includeHeaders? (default true), bom? (default true — prefixes the UTF-8 BOM so Excel detects the encoding) }.
returnsstringRFC 4180 CSV: fields containing quotes, the delimiter or newlines are quoted with doubled quotes; lines are joined with CRLF.

DataTableHandle<T> (ref) props

PropTypeDefaultDescription
exportCsv(options?: DataTableExportOptions) => voidDownloads 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() => voidRemoves the view state saved under stateKey (no-op without one).
startEdit(rowKey: string, columnKey: string) => voidOpens the inline editor for a cell (requires `editing` + an editable column).
stopEdit(commit?: boolean) => voidCloses an open cell editor. Pass `true` to attempt a commit first.
elementHTMLDivElement | nullThe table's root element for measurements or scrolling.

Keyboard

KeyAction
TabMoves through sort buttons, filter inputs, checkboxes and the pager.
Enter / SpaceActivates the focused sort button (cycles asc → desc → off).
SpaceToggles the focused row / select-all checkbox.
Shift + ClickOn a row checkbox (multiple selection mode): selects the visible-row range between the last toggled row and the clicked row, respecting isRowSelectable.