@jacopozanti/data-table
v0.121.0
Published
A feature-rich React data table (sorting, grouping, row selection, hover preview, keyboard nav, and a type-safe filter system) built on TanStack Table and Base UI.
Maintainers
Readme
@jacopozanti/data-table
A feature-rich React data table: sorting, grouping, row selection, global search, a hover preview pane, keyboard navigation, and a type-safe filter system. Built on TanStack Table and Base UI.
Install
npm install @jacopozanti/data-table tailwindcss tw-animate-cssRequires React 18.2+ or 19 and Tailwind v4 as peer dependencies.
tw-animate-css is an optional peer (recommended — it powers the
popover/tooltip/dropdown transitions; shadcn/ui projects already have it).
Everything else the table needs is installed automatically.
Setup
With Tailwind v4 (re-themeable)
In your app's main stylesheet (the one Tailwind processes), pull in Tailwind, the animation utilities, and our design tokens:
@import "tailwindcss";
@import "tw-animate-css";
@import "@jacopozanti/data-table/theme.css";That's it — our theme.css already registers itself as a Tailwind source, so
the utilities our components use end up in your bundle automatically. This path
lets you re-theme via the CSS custom properties.
Note on bundle size. That self-registration means Tailwind generates every class our bundle mentions (~50 KB), which is wasted if you import the stylesheet into a shared design-system entry that most apps don't use the table in. Two ways out:
- import
theme.cssonly in the app/route that renders the table; or- import
@jacopozanti/data-table/tokens.cssinstead — identical tokens with no Tailwind source registration — and get the classes from the precompiledstyles.cssbelow.| Entry point | Tokens | Utilities | Re-themeable | | ------------ | ------ | ----------------------------- | ---------------------- | |
theme.css| ✅ | generated by your Tailwind | ✅ | |tokens.css| ✅ | none (pair withstyles.css) | ✅ | |styles.css| ✅ | precompiled, no preflight | ✅ (override the vars) |
Without Tailwind (precompiled CSS)
Not using Tailwind (or on v3, CSS Modules, plain CSS)? Import the precompiled stylesheet once — it ships every class the components use plus the tokens, and contains no page reset (no Tailwind preflight), so it won't touch your app's styles:
import "@jacopozanti/data-table/styles.css";You can still re-theme by overriding the token custom properties (--primary,
--background, --radius, --shadow-md, …) after the import — the
rounded-* / shadow-* the table uses derive from them. tailwindcss is then
not needed as a peer dependency.
Already using shadcn/ui? Your theme wins automatically: the package ships
its token defaults inside @layer base, so the shadcn tokens you already have
in :root / .dark (unlayered) always take precedence — no matter the import
order. The table just inherits your look.
Dark mode: add a dark class to any ancestor element (e.g.
<html class="dark">).
Usage
Give the table a height-constrained flex container so its body can scroll.
import { DataTable } from "@jacopozanti/data-table";
import type { TableColumn } from "@jacopozanti/data-table";
type Person = { id: string; name: string; age: number; role: string };
const data: Person[] = [
{ id: "1", name: "Ada Lovelace", age: 36, role: "engineering" },
{ id: "2", name: "Linus Torvalds", age: 54, role: "operations" },
];
// `size` is optional (xs | s | m | l | xl | fill | auto | "96px") and
// defaults to `fill` —
// omit it for columns that should expand to fill the remaining width.
const columns: TableColumn<Person>[] = [
{ id: "name", header: "Name", renderCell: (r) => r.name },
{ id: "age", header: "Age", size: "s", renderCell: (r) => r.age },
{ id: "role", header: "Role", size: "m", renderCell: (r) => r.role },
];
export function People() {
return (
<div className="flex h-screen flex-col p-4">
<DataTable
columns={columns}
data={data}
getRowId={(r) => r.id}
onRowClick={(r) => console.log("clicked", r)}
/>
</div>
);
}What a bare table gives you
<DataTable columns={columns} data={data} />is a table: header, rows, row selection — and no toolbar at all. The search box and all three menus are opt-in, because chrome that arrives unbidden is chrome every consumer then has to switch off:
<DataTable columns={columns} data={data} search />The toolbar row itself only exists once something is in it — no empty band
above the table. Note that filtering.columns describes what can be filtered and
does not ask for the button: a host that drives filters itself, or offers the
control in its own chrome, wants the one without the other. Pass
toolbar.menus={{ filter: true }} for the built-in one.
What is on by default can be switched off, so wrapping the table in your own design-system component doesn't require CSS against its internals:
<DataTable columns={columns} data={data} selection={false} />Selection's checkboxes are revealed on hover — an unselected table shows none, so nothing competes with the data, and the column keeps its width so the row doesn't shift when you point at it. One appears anyway once its row is selected, once anything else is, or when the row has keyboard focus.
When selection is the point of the table rather than an occasional action — a picker, a bulk-edit screen — or on touch, where there is no hover to reveal anything with, keep them on screen:
<DataTable columns={columns} data={data} selection={{ alwaysVisible: true }} />Each of these removes the DOM rather than hiding it, and the matching hotkeys go
inert with it (a menu that isn't mounted can't have its F/G open a popup
anchored to a collapsed rect). Individual shortcuts go through
hotkeys={{ selection: false }}.
The menus you do ask for sit next to each other, each its own button — no wrapper to write and nothing to un-style.
To match a design system's metrics without classNames overrides, use the
typography knobs (per table or on the provider):
<DataTable
style={{
columnDividers: "none",
heights: { row: 36 },
cellPaddingX: 12,
fontSize: 13,
rowRadius: 10,
}}
/>One visual style
There is no container variant: the table has a single look — dense 13px text on tight cell padding, softly rounded row backgrounds, no vertical rules, plain sans headers.
Grouping is part of that look. A group header renders as an inset strip
rather than a full-bleed row: --dt-band fills it, a 2px border in the surface
colour above and below is the inset, and the row's radius rounds the two ends.
And grouping.guides is on by default, so a grouped table draws the tree lines
from each header down into its rows.
The greys are one neutral ladder, and every step is derived from
--foreground over --background rather than from --accent or --border:
| Step | Mix of --foreground |
| -------------------- | --------------------- |
| --dt-band | 3.5% (opaque) |
| --dt-row-hover | 5% (opaque) |
| --dt-surface-hover | 6% (opaque) |
| --dt-divider | 8% (translucent) |
| --dt-guide | 16% (translucent) |
| --dt-guide-hover | 42% (translucent) |
Deriving them this way is what makes the look survive a change of palette: the
contrast of each step is fixed, so a brand accent can't decide whether the
band is visible at all, and one formula covers both themes because
--foreground is what flips. Fills are opaque because the band is sticky (rows
scroll under it); lines are translucent so they read over a hovered or selected
row instead of painting a grey bar across it. A menu row and a table row
therefore highlight alike — the interactive chrome is on the same ladder.
Two things stay yours, because they are brand rather than structure:
--dt-row-selected / --dt-checked (both --primary) and the accent hue.
Everything else about the style is a token or a prop rather than a preset:
heights, widths, style.cellPaddingX, style.fontSize, style.rowRadius, style.columnDividers,
style.rowDividers, and the 38 --dt-* variables below.
Defaults
A <DataTable columns data /> is a table and nothing else: header, rows, and a
checkbox that appears when you hover a row. No toolbar, no footer, no search
box, no menu buttons, nothing sorted. Chrome that arrives unbidden is chrome
every consumer has to switch off, so none of it does.
What is on is what a table stops being a table without — and each of those is one prop away from off.
On unless you say otherwise
| Feature | Default | Off with |
| ------------------ | ---------------------------------- | ---------------------------- |
| Row selection | on, revealed on hover | selection={false} |
| Sorting | on, on every column | sorting={false} |
| Grouping | on (nothing grouped yet) | grouping={false} |
| Column pinning | on, from the header's context menu | pinning={false} |
| Keyboard shortcuts | all six on | hotkeys={false} |
| The header row | mounted | layout={{ header: false }} |
Off unless you ask
| Feature | Turn on with |
| --------------------------------- | --------------------------- |
| The toolbar, and everything in it | toolbar={{ … }} |
| The search box | search |
| Filter / group / columns menus | toolbar={{ menus: true }} |
| The footer, pages and summary | footer={{ … }} |
| The peek pane | preview |
| Drag-to-reorder | reorder |
| Row detail panels | rowDetail={{ render }} |
filtering is the odd one out: there is no switch, because without its
columns there is nothing to filter.
The values behind the switches
| | Default | |
| ----------------------------------------- | --------------- | --------------------------------------------- |
| locale | "en" | |
| style.density | "comfortable" | |
| style.rowDividers / columnDividers | "none" | no rules, either way |
| style.menuStyle | "default" | rounded popovers |
| selection.barStyle | "pill" | |
| grouping.rowStyle | "band" | the inset strip |
| grouping.guides | true | tree lines from a band to its rows |
| search.debounce | 300ms | 0 applies every keystroke |
| sorting.strategy / filtering.strategy | "client" | |
| layout.scroll | "container" | the table owns its scroll |
| layout.responsiveBasis | "container" | showFrom measures the table, not the window |
| layout.endReachedOffset | 240px | about six rows |
| rowDetail.max | 1 | panels open at once |
| footer.pagination.pageSize | 50 | options [10, 25, 50, 100] |
| rowActions.in | "both" | the column and the right-click menu |
Per column
| | Default |
| ----------------- | ------------------------------------------------------------------------------------------- |
| size | "fill" — "auto" for a chips column |
| padded | true |
| truncate | true for text, false for a bool/copy/chips type or any padded other than true |
| align | "start" — "end" for number, "center" for bool |
| stopRowClick | false — true for copy |
| inline position | "after" |
Numbers the table picks on its own
Four sizes and one threshold are not props at all, because nothing good comes of tuning them per table:
- the service columns are 20px of grouping indent, 32px for the checkbox,
28px for the detail expander and 24px for the drag handle — all four
overridable together through
style.widths; - a
fillcolumn never goes below 160px, and anautoone never below 80, or a narrow table would collapse a column into its own ellipsis; - row virtualization turns itself on above 100 rows, and off again
under grouping, a detail panel or
scroll="page", each of which needs every row to exist.layout={{ virtualized }}decides it yourself; loadingdraws 8 skeleton rows.
Props
Twenty-four props, two of them required. Every feature is one prop that is both
its switch and its settings — sorting={false} turns it off,
sorting={{ strategy: "server" }} configures it, leaving it out takes the
default — so there is no sorting to disagree with a sorting, and no
state prop that had to be called something else because the plain name was
already a boolean.
Three features can be on without being yours to change: pass
enabled: false inside the config and the table still sorts, groups or holds
a selection while the reader cannot alter it. false on the prop itself is the
shorthand for turning the whole thing off.
One thing to know before you nest: an object prop replaces, it does not
merge. grouping={{ rowStyle: "row" }} drops a by set somewhere else in
the same element. A DataTableProvider does merge, key by key.
The table
| Prop | Type | Description |
| ------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| columns | TableColumn<TData>[] | Column definitions. Required. |
| data | TData[] | Row data. Required. |
| getRowId | (row) => string | A stable id per row. Without it rows fall back to their index, which breaks selection and keyboard focus as soon as the data is sorted or replaced. |
| locale | Locale \| string | UI language for the built-in strings. Default "en". |
| loading | boolean | Skeleton rows instead of data. Wins over an empty data. |
| emptyState | ReactNode \| ((ctx) => ReactNode) | What stands in for the rows when there are none. The function form receives { search, filters, filtered, total, reset } — the difference between "nothing yet" and "nothing matches «acme»". |
| onRowClick | (row) => void | A row was clicked, or activated with Enter. |
| rowCount | number | The server holds the rest of the rows: the table renders the page it was given and counts against this total. Also makes sorting inert unless sorting.strategy is "server", and removes the header's select-all. |
Features
| Prop | Type | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| search | boolean \| { placeholder, debounce, value, onChange } | The toolbar's search box. Off by default. debounce is 300ms; onChange makes the search yours (the table stops filtering data). |
| sorting | boolean \| { enabled, value, onChange, strategy } | On by default. strategy: "server" means the rows arrive ordered. |
| grouping | boolean \| { enabled, by, onChange, expanded, onExpandedChange, guides, rowStyle, renderRow, onRowClick } | On by default. by is the column ids, outermost first; rowStyle: "row" dresses a band as one of the rows. |
| filtering | { columns, strategy, value, onChange, options, faceted } | Column filters. The one feature with no boolean form, since it needs its columns. options is required under strategy: "server". |
| selection | boolean \| { enabled, row, alwaysVisible, groupRows, value, onChange, actions, renderCount, barStyle } | On by default. row decides which rows qualify; actions are the bulk actions and the bar they live in. |
| preview | boolean \| { render, className } | The hover/peek card — hold Space, Shift+Space to pin. A render implies it. |
| reorder | boolean \| "always" \| { always, onReorder } | Drag-to-reorder. The table reorders nothing itself: apply event.rows. |
| rowActions | RowAction[] \| ((row) => RowAction[]) \| { items, in } | Actions at the end of a row. The function form lets the actions themselves differ per row. in: "menu" frees the whole column. |
| rowDetail | { render, max } | An expandable panel under a row. max open at once, default 1. |
| columnVisibility | { hidden, onChange } | Which columns are on screen — what the toolbar's columns menu writes. |
| columnSizing | { value, onChange, enabled } | Widths the reader dragged — off until enabled. |
| pinning | boolean | Freezing columns at an edge, from the header's context menu. Default true. |
| hotkeys | boolean \| { search, filter, group, navigation, selection, preview } | Keyboard shortcuts, all at once or one at a time. Default true. |
Chrome
| Prop | Type | Description |
| --------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| toolbar | { title, titleActions, searchActions, actions, menus } | The row above the table. Absent, there is no toolbar. menus is true or { filter, group, columns }, all off by default. |
| footer | { pagination, summary } | The row below it. pagination is true or { pageSize, pageSizeOptions, state, onChange }. Either half alone is enough for the footer to exist. |
Container
| Prop | Type | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| layout | { header, scroll, virtualized, responsiveBasis, onEndReached, endReachedOffset } | Header row, who owns the scroll, virtualization, and what "narrow" is measured against. |
| style | { density, heights, widths, cellPaddingX, fontSize, rowRadius, rowDividers, columnDividers, menuStyle, classNames } | Everything visual. |
Column
The header of a column
header is the column's name, and a bare string is almost always what you
want. The object form is for the header that needs to say more, or less:
{ id: "state", header: { label: "State", hide: true } }
{ id: "est", header: { label: "Est.", tooltip: "Estimate (story points)" } }
{ id: "title", header: { label: "Title", render: () => <><Icon /> Title</> } }| Key | What it does |
| ----------- | ------------------------------------------------------------------- |
| label | The name. Required, and used even when nothing is painted. |
| hide | Keep the name out of the header cell. Default false. |
| tooltip | Shown on hover over the header cell — for a name that does not fit. |
| className | Extra classes on the <th>, merged after the internal ones. |
| render | Draw the header's content yourself. |
label is required whatever else you pass, because it is what names the column
in the columns menu, the filter menu, the preview pane, the CSV export and the
accessibility tree — the places a name is actually read.
hide and render are visual only. Both keep the label in the
accessibility tree — hidden means visually hidden, and drawn content is marked
aria-hidden with the label standing in for it. A sortable column with no
accessible name is a control announced as nothing, and its aria-sort would
qualify nothing. The cell keeps its sort control, its context menu and its sort
state in every case.
Cells that hold a control, not a sentence
Two per-column knobs exist for the case where a cell is a control:
{ id: "menu", header: "", size: "xs", padded: "fill", truncate: false,
renderCell: (r) => <RowMenu row={r} /> }truncate (default true for text, false for a bool/copy type or
any padded other than true) decides whether overflow gets an ellipsis. It
used to be unconditional, which put a … under the control in every row of a
narrow column: a 28px button plus 24px of padding in a 48px column is
overflowing, so the browser drew the mark even with no text to abbreviate.
padded has three states, and the middle one changed:
| | Padding | Layout |
| ---------------- | ----------------------------------------------- | ------------------------------------------------------------------- |
| true (default) | --dt-cell-px + the density's vertical padding | unchanged |
| false | none | unchanged — your cellClassName decides the spacing |
| "fill" | none | the content is stretched to every edge; align becomes justify-* |
Migration.
padded: falseused to mean what"fill"means now. Stretching is done by absolutely positioning the content, which resolves against the cell's padding box — so any padding you put back withcellClassNamewas ignored and the control sat flush against the border. If you pairedpadded: falsewith a full-cell button (DataTableCellButton, or anything withh-full), change it topadded: "fill".
Columns drawn inside another column
A row is usually about one thing, and the rest is metadata beside it. Mark that
one primary — it takes the width the fixed columns leave — and attach the
metadata to it with inline:
const columns: TableColumn<Issue>[] = [
{
id: "state",
header: "State",
inline: "before",
renderCell: (r) => <StateIcon state={r.state} />,
},
{ id: "key", header: "ID", size: "m" },
{ id: "title", header: "Title", primary: true },
{ id: "labels", header: "Labels", inline: "end", type: "chips" },
{
id: "due",
header: "Due",
inline: "end",
renderCell: (r) => <Pill>{r.due}</Pill>,
},
];[before…] [ the host's content, truncating ] [after…] [end…][end…]
↑ compressesinline: true is "after", a bare position is that position, and
{ column: "key", position: "end" } names a host other than the primary one.
It is still a column. It sorts, groups, filters, searches and exports
through its accessorFn; it has a line in the columns menu that hides it;
showFrom still drops it when the table is narrow; and renderPreviewCell
still gives it a labelled field of its own in the preview pane — compact in the
row, spelled out in the pane. What it gives up is what a track was for: size,
align, padded, truncate and pinned do nothing, and it has no header cell
(its header still names it in the menus and the preview).
Only the end cluster compresses. As the host runs out of room its items
slide over each other, up to 32px — about half the narrowest pill, so even that
one still reads. You lose the tail of each rather than the whole of the last.
before and after are small by nature and stay as they are.
Responsive by table width
The table measures itself (a ResizeObserver on its own box, not a media
query), so a table in a sidebar, a split pane or a modal adapts to the room it
actually got. The width lands in one of five buckets — xs (from 0), s (480),
m (768), l (1024), xl (1280) — and reaches your code three ways:
const columns: TableColumn<Issue>[] = [
{
id: "title",
header: "Title",
// Every renderer gets the current bucket: recompose instead of overflowing.
renderCell: (row, { width }) => (
<span>
{row.title}
{!atLeastWidth(width, "m") && <em> · {row.assignee}</em>}
</span>
),
},
// Below `m` this column is not mounted at all: no header, no cells, no share
// of the width budget, and nothing to toggle in the columns menu.
{
id: "updated",
header: "Updated",
size: "s",
showFrom: "m",
renderCell: (r) => r.updated,
},
];The bucket is also on the DOM as data-dt-width on the table root, so CSS can
key off it without measuring anything itself:
[data-dt-width="xs"] .my-chip {
display: none;
}atLeastWidth(width, min) is exported for the comparison (min omitted means
"always"), along with TABLE_WIDTH_BREAKPOINTS if you need the raw pixel
thresholds. grouping.renderRow receives width in its context too.
A table that can't be measured — server-rendered, display: none, or in a test
environment without layout — reports xl rather than collapsing to xs, so
nothing is hidden for lack of a measurement.
Resizing a column
Off until asked for. columnSizing={{ enabled: true }} puts a handle on each
header's right edge — revealed on hover — and the column follows the pointer
while it moves.
<DataTable columnSizing={{ enabled: true }} />
<DataTable // controlled, for a saved view
columnSizing={{ enabled: true, value: widths, onChange: setWidths }}
/>A width set this way outranks whatever the column declared — a step, a length,
or a measured auto — and goes on outranking it. The auto measurements reset
whenever data changes; a width someone chose does not, or a refresh would
undo it and read as the drag having failed. Double-click the handle to hand
the column back to its size, measurement included.
A fill column has no handle: it is the one that takes what is left over, and
pinning it down would leave the row with nothing to absorb the slack. The floor
is 40px — lower than the 80px an auto column gets, because that one keeps a
column from collapsing under its header while this one is you deciding a column
may be a sliver.
Pinning a column
Freeze columns while scrolling horizontally, either declaratively or at runtime from the header context menu (right-click → Pin to left/right, Unpin):
const columns: TableColumn<Person>[] = [
{ id: "name", header: "Name", pinned: "left", renderCell: (r) => r.name },
{ id: "age", header: "Age", size: "s", renderCell: (r) => r.age },
];Pinned columns move to the table edge and stick during horizontal scroll.
When something is pinned left, the selection/grouping columns freeze with it;
when pinned right, the row-actions column freezes too. Notes: a pinned fill
column is resized to l (sticky offsets need fixed widths) and sticky cells use
the --dt-surface token as their backdrop. Group header rows stick to the top
during vertical scroll, but are not frozen horizontally when columns are pinned.
Column Types
The seven types
A column can say what kind of value it holds, and get a renderer for it —
so renderCell is optional:
const columns: TableColumn<Account>[] = [
{ id: "ref", header: "Ref", type: "copy" },
{ id: "holder", header: "Holder", type: "text" },
{
id: "balance",
header: "Balance",
type: "number",
format: { currency: "EUR" },
},
{
id: "opened",
header: "Opened",
type: "date",
format: { dateStyle: "short" },
},
{ id: "active", header: "Active", type: "bool" },
{ id: "token", header: "Token", type: "password" },
];| type | The cell | Also |
| ---------- | ------------------------------------------------------------------------------------------------- | ----------------------------- |
| text | the value as a string | — |
| number | localized digits; format.currency / format.decimals | align: "end" |
| date | localized date; format.dateStyle (short/medium/long/time/datetime), format.timeZone | — |
| bool | a check, or a dash — and neither for null, which isn't false | align: "center" |
| copy | the value plus a copy button, revealed on row hover | stopRowClick |
| chips | a Chip[] as rounded pills | no ellipsis |
| password | a fixed-width mask | out of the search and the CSV |
chips — pills in a cell
The cell's value is an array of chips (a lone chip is accepted too), and each one renders as the pill the rest of the table already uses for labels, cycles and due dates: 22px tall, fully rounded, a hairline border on the table's surface, 12px text.
import type { Chip, TableColumn } from "@jacopozanti/data-table";
import { Tag } from "lucide-react";
type Ticket = { id: string; labels: Chip[]; stage: Chip };
const rows: Ticket[] = [
{
id: "1",
labels: [
// Neutral: the table dresses it.
{ id: "infra", label: "Infrastruttura", icon: <Tag /> },
// Colour on the text and the icon, over the table's own surface.
{ id: "sales", label: "Sales", icon: <Tag />, color: "#b08800" },
],
// Filled: a background makes it a solid pill.
stage: {
id: "review",
label: "In review",
color: "#fff",
backgroundColor: "#5e6ad2",
},
},
];
const columns: TableColumn<Ticket>[] = [
{ id: "labels", header: "Labels", type: "chips", size: "l" },
{ id: "stage", header: "Stage", type: "chips", size: "m" },
];| Field | |
| ----------------- | ----------------------------------------------------------------- |
| id | identity of the chip, and its React key |
| label | the text in the pill |
| icon | rendered before the label; a lucide-react icon is sized to 12px |
| color | colour of the label and the icon |
| backgroundColor | fill of the pill; the border takes the same colour |
The pill's geometry is the table's and its colours are yours — a label's hue is data, where the shape is the table's one visual style. A chip with a background borders in that same colour, so a filled pill keeps the exact size of a neutral one instead of growing an outline.
Contrast is yours to get right, and color alone can't be. The surface
under a chip flips with the theme, and no single hex clears 4.5:1 on both a
near-white and a near-black one: a colour dark enough to read on the first is
too dark for the second. Two patterns hold in both themes — put the hue in the
icon (decoration, with no contrast minimum, which is how the reference
screen's own label dots work) and leave the label at the table's foreground, or
give color and backgroundColor together so the chip brings both sides of
the ratio. Colouring the text alone means supplying a per-theme value.
Two things follow the labels rather than the objects, because String(value)
on an array of chips is [object Object]: the global search matches a row
whose chip labels contain the term, and toCsv exports them joined by
", ". Sorting and grouping still compare the raw value, so a chips column you
want sorted wants a sortValue that returns something comparable — bending
its accessorFn would change what the search finds and what the CSV writes
along with it.
An empty array renders nothing at all — not an empty pill — and a value that isn't chip-shaped is skipped rather than thrown over.
A chips column sizes itself. It defaults to size: "auto" — as wide as its
widest pills need, and no wider — because a pill cut in half is unreadable where
a sentence still reads up to its ellipsis. It grows into the room the table has,
never into a horizontal scrollbar: the fill column gives way first, down to its
own floor. When there is no room left to take, the chips slide over each
other (up to 32px each, later pills painting over earlier ones) so you can
still read the start of every one. An explicit size is still a request and
wins over the default.
The type only fills gaps. A renderCell you wrote wins, and so do align
and stopRowClick — which is what makes type safe to add to a column that
already renders the way you want. A column with neither type nor
renderCell still renders String(value), as before.
Dates format in UTC unless format.timeZone says otherwise, and the locale
comes from the table's locale prop rather than the machine's. Both of those
are for one reason: this table server-renders, and a format read from the
environment differs between the server and the browser that hydrates it — React
reports the mismatch and the text changes under the reader. Pass
format.timeZone when you want the reader's own zone and can accept the first
paint correcting itself. A locale that Intl won't accept (it takes any
string, and registerLocale allows arbitrary names) falls back to "en", not
to the browser's.
passwordmasks a display, it is not a permission. The mask holds on the two paths that would otherwise hand the value back — the global search skips the column, andtoCsvkeeps its header while exporting nothing — but the value is still in the row object you passed, and arenderPreviewCellorrenderGroupValueyou write yourself will show it. Don't put a secret in a table and consider it hidden.
The types are deliberately few. Anything else is a renderCell, which is the
one thing type can never be better than.
Types and their filters
createColumnHelper<TData>() builds columns with autocomplete and type-checking
on id (from the keys of TData), and an accessorFn that defaults to that
field's value:
import { createColumnHelper } from "@jacopozanti/data-table";
const col = createColumnHelper<Person>();
const columns = [
col.accessor("name", { header: "Name" }), // no renderCell: the value as a string
col.accessor("age", { header: "Age", size: "s", align: "end" }),
col.display({
id: "actions",
header: "",
renderCell: (r) => <RowMenu row={r} />,
}),
];accessor(id, …) flags a typo in id at compile time; display(…) is for
non-data columns (free-form id, and the cell is yours to render).
A type works through the helper exactly as it does on an object literal.
Both builders hand back a plain TableColumn, so the type's renderer draws the
cell and a renderCell you write still wins over it:
type Task = {
id: string;
title: string;
dueAt: Date;
labels: Chip[];
cost: number;
};
const col = createColumnHelper<Task>();
const columns = [
col.accessor("title", { header: "Title", primary: true }),
// Formatted by the `date` renderer — in UTC, in the table's locale.
col.accessor("dueAt", {
header: "Due",
type: "date",
format: { dateStyle: "datetime" },
size: "m",
}),
// Pills, not the comma-joined array.
col.accessor("labels", { header: "Labels", type: "chips" }),
col.accessor("cost", {
header: "Cost",
type: "number",
format: { currency: "EUR" },
}),
// A cell you write beats the type it was given.
col.accessor("id", {
header: "",
type: "copy",
renderCell: (r) => <RowMenu row={r} />,
}),
];Up to 0.106.0
accessor(…)filled in arenderCellof its own, which — a written renderer winning over the type's — meant a built column silently lost itstype:dateprintedString(row.dueAt)andchipsthe joined array. It now leaves the cell alone unless you give it one, so the value-as-a-string fallback is the table's, and it reads through youraccessorFn.
Toolbar
Title, slots and menus
See Custom toolbar actions below for the full
recipe. title sets the toolbar heading; the three slots inject content after
the title, after the search bar, and after the built-in buttons.
<DataTable columns={columns} data={data} toolbar={{ title: "Tasks" }} />Custom toolbar actions
Add your own actions to the toolbar via toolbar.titleActions (after the title),
toolbar.searchActions (after the search bar, before the filter buttons) or actions
(after them). For buttons that match the built-in filter/group/column controls
use the exported DataTableIconButton / DataTableButton, which match the
table's own toolbar buttons:
import {
DataTable,
DataTableIconButton,
DataTableButton,
} from "@jacopozanti/data-table";
import { Download, Plus, RefreshCw } from "lucide-react";
<DataTable
columns={columns}
data={data}
toolbar={{
titleActions: (
<DataTableIconButton icon={Plus} label="New" onClick={onNew} />
),
searchActions: (
<>
<DataTableIconButton
icon={RefreshCw}
label="Refresh"
onClick={onRefresh}
/>
<DataTableButton icon={Download} onClick={onExport}>
Export
</DataTableButton>
</>
),
}}
/>;DataTableIconButton takes icon + label (used for aria-label and the
tooltip; pass tooltip={false} to disable, or a node to customize). Both
components forward the usual <button> props (onClick, disabled, …), and
render the same way inside the toolbar or anywhere else.
Both share one recipe with the built-in filter/group/column triggers: a 28px
square (a 28px-tall pill for the text one), a hairline border, no fill, muted
until you touch it, and the same neutral hover step as a row. The menus sit
beside each other a hair apart rather than welded into a segmented control —
each is its own affordance, and a group's squared-off inner edges read as one
wide control cut into slices. Because it is one recipe, your action can't be
told apart from ours standing next to it: the reference screen's own toolbar
row is DataTableIconButton.
Search
One box, over every column. It is off by default — a table is not obliged
to offer a search — and search on its own is the whole of it:
<DataTable columns={columns} data={data} search />It matches through each column's accessorFn, which is the same channel
sorting, grouping and the CSV export read. So a column searches by whatever it
sorts by, and a renderCell that dresses a value up does not change what the
box finds. A password column is the one exception: it is kept out, because a
search that found the clear text would leak it by result set.
The wait
Typing filters after a 300ms pause, not on every keystroke: on a large table that is thousands of rows re-filtered per character, and on a server-side search one request per character.
The wait is on the effect and never on the field — the text appears as fast as
it is typed, and what is delayed is the filtering. Two things skip it, because
both say the typing is over: Enter sends what is in the field at once, and
emptying the field applies immediately, since going back to no filter is not a
search anyone is still composing.
<DataTable search={{ debounce: 0 }} /> // every keystrokeNaming the field
Nothing visible labels that input, so its placeholder is the only thing naming it — which is why the placeholder is also its accessible name. A field that reads "Search by name" to the eye and "Search" to a screen reader is two controls, not one.
<DataTable search={{ placeholder: "Search by title…" }} />Searching somewhere else
onChange makes the search yours: the table stops filtering data by text and
only reports the term, already debounced, so nothing has to be wrapped.
const [q, setQ] = useState("");
<DataTable data={rowsFromServer} search={{ value: q, onChange: setQ }} />;value alone controls the text without taking over the filtering — for
restoring a term from a URL, say.
K focuses the box from anywhere on the page, and never fires while you are
already typing in one.
Sorting
On by default, on every column, and nothing is sorted until someone asks. A default order would silently disagree with a list whose order already means something — a backlog, a manual ranking, the order an API chose.
A click cycles ascending → descending → none. The third state is the point: an
order you applied can be taken back off, not only reversed. Shift on a second
header adds a key rather than replacing the first, so a table can be sorted by
state and then by title inside each state.
Sorting reads a column's accessorFn, which is also what the search matches
and the CSV exports — one channel, so a column sorts by the same value it is
searched by. sortValue is how a column steps out of that channel without
dragging the other two with it.
Sorting by something else
A priority column renders "low"/"med"/"high" and sorts alphabetically: high,
low, med. sortValue says what to compare instead — a key, not a comparator,
so there is no -1/0/1 to get backwards and no missing value to remember.
const rank = { low: 0, med: 1, high: 2 };
<DataTable
columns={[
{ id: "priority", header: "Priority",
renderCell: (r) => r.priority,
sortValue: (r) => rank[r.priority] },
]}
/>The order moves and nothing else does: the search still finds the row whose
cell says "high", and the CSV still writes the word. Bending accessorFn to
return 0/1/2 would have changed both.
Two more, on the same column:
| | |
| --- | --- |
| sortUndefined | where rows with no value go — "first", "last" (default), or false to sort them among the rest |
| sortDescFirst | the first click sorts descending |
A column's type also informs the comparison. A date column whose accessor
hands back "March 9, 2026" is ordered by the instant rather than by the
spelling — as text the 9th lands after the 10th — and a number column fed
"9" and "10" counts them as numbers. Only the order: the search and the
export keep reading what the accessor returned.
Turning it off
<DataTable sorting={false} /> // every header inert
<DataTable columns={[{ …, enableSorting: false }]} /> // one columnfalse on the table makes every header inert: no click, no keyboard sort, no
aria-sort. Per column it is for the cell that holds a control rather than a
value — there is nothing meaningful to order by, and a header that cycles
through orders nobody can read is a control that lies.
A third case sits between them: an order the table holds and the reader cannot change.
<DataTable sorting={{ enabled: false, value: [{ id: "date", desc: true }] }} />Controlled, and server-side
const [sorting, setSorting] = useState<SortingState>([]);
<DataTable sorting={{ value: sorting, onChange: setSorting }} />;strategy: "server" means the rows arrive already ordered: the table tracks
the state and reports it, and renders data as given. Multi-sort still works —
the whole SortingState goes over, and how many of its keys a source honours
is the source's business.
<DataTable
data={pageFromServer}
rowCount={312}
sorting={{ strategy: "server", value: sorting, onChange: refetch }}
/>rowCount without strategy: "server" makes sorting inert, and that is
deliberate. rowCount says the server slices the rows, so the table holds one
page; sorting it client-side would reorder the twenty rows in hand and present
them as the top twenty overall. There is no click left that could produce a
correct order, so the headers stop offering one — and a dev build says why.
That guard overrules a per-column enableSorting: true, the one place in this
API where a column does not win.
A sort change returns to the first page on either strategy: page 7 of one order holds different rows from page 7 of another, and staying put lands the reader somewhere they did not ask to be.
Grouping
Grouping with aggregations
Group rows by one or more column ids; give a column an aggregate to show a
total/average/… in each group header.
const columns = [
{
id: "estimate",
header: "Est.",
aggregate: "sum",
renderCell: (r) => r.estimate,
},
// …
];
<DataTable columns={columns} data={data} grouping={{ by: ["status"] }} />;All groups start expanded. Applying a grouping runs in a React transition, with a loading overlay while the (potentially heavy) re-render is in flight.
The grouped column stays in the rows. Its band already announces the
value, but the cell may be the only way to act on it — a status you can
change, a link, a menu — and dropping it from the rows would take that away for
as long as the grouping is on. It also keeps its header, and its place: nothing
is reordered. Pass the column in columnVisibility.hidden to drop it anyway.
That leaves one renderer doing two jobs, so a column can split them:
renderGroupValue labels the band, renderCell fills the rows. Useful when the
cell can afford to be compact — the band above it says what the group is —
while the band's label has to stand on its own:
{
id: "state",
header: "State",
size: "xs",
// In the row: the glyph alone.
renderCell: (r) => <StateIcon state={r.state} title={r.state} />,
// In the band: the glyph and its name.
renderGroupValue: (value) => (
<><StateIcon state={value as State} /> {String(value)}</>
),
}It falls back to renderCell (applied to the group's first row) and then to the
raw value, so omitting it keeps today's behaviour.
Clicking a band anywhere toggles its group, and the chevron is a real
<button aria-expanded> named after the group — the <tr> can't carry
aria-expanded itself (that attribute is only valid on a row inside a
treegrid), so the row exposes data-dt-expanded for styling and tests
instead. A grouping.renderRow band therefore owns its own control: the context
hands you expanded for the attribute and toggle for the action.
renderGroupRow={({ value, expanded, toggle }) => (
<button
type="button"
aria-expanded={expanded}
aria-label={String(value)}
// The band toggles on click too, so a control inside it has to stop there.
onClick={(e) => { e.stopPropagation(); toggle(); }}
>
<ChevronDown />
</button>
)}Note that grouping disables row virtualization, so grouping a very large dataset renders every leaf row — group by a lower-cardinality field there.
Groups that the data doesn't contain
Grouping comes from the rows, so a group with no rows isn't there — and "Awaiting approval · 0" is a fact, not an emptiness to hide. Declare the groups a column is expected to produce and they are filled in, in that order:
{ id: "status", header: "Status", groupValues: ["todo", "review", "done"] }Declared values render in the declared order; a value the data has but the declaration doesn't follows them rather than disappearing. Only the outermost grouping level is filled this way — a nested empty group has no parent to hang from.
Tree lines from a band to its rows
On by default. Draws a trunk down the grouping indent column, curving 90°
into the middle of each row and stopping at the group's last one. Pass
grouping.guides={false} for a flat indent — the click-to-collapse on the indent
column goes with it.
<DataTable columns={columns} data={data} grouping={{ by: ["state"] }} />Hovering any part of a group's tree lights all of it (--dt-guide-hover) and a
click collapses that group — a pointer shortcut for the band's own toggle, which
stays the keyboard-reachable control.
Colour it with --dt-guide (defaults to --dt-divider). Note that overriding
--dt-guide on the table does not change --dt-guide-hover: a var() inside
a custom property resolves where the property is declared, so set both. The
lines are drawn
with borders and a corner radius rather than an SVG path, so the elbow lands at
exactly half of any row height — a path would need that height in JS, and a
stretched viewBox would distort the curve.
The pieces are laid end to end, never stacked: on a row where the group continues, the elbow draws the trunk down to the top of its curve and a second piece carries it from there to the row's foot. The stroke is translucent — it has to read over a hovered row instead of painting a bar across it — so two segments sharing pixels would mix to twice the alpha, and that half of the trunk would come out visibly brighter than the rest.
Filter
Adding filters
Build type-safe filter columns with createColumnConfigHelper and pass them as
filtering.columns. They show up in the toolbar's filter menu.
import { DataTable, createColumnConfigHelper } from "@jacopozanti/data-table";
import { Hash, Tag, User } from "lucide-react";
const dtf = createColumnConfigHelper<Person>();
const filterColumns = [
dtf
.text()
.id("name")
.accessor((r) => r.name)
.displayName("Name")
.icon(User)
.build(),
dtf
.number()
.id("age")
.accessor((r) => r.age)
.displayName("Age")
.icon(Hash)
.build(),
dtf
.option()
.id("role")
.accessor((r) => r.role)
.displayName("Role")
.icon(Tag)
.build(),
] as const;
<DataTable
columns={columns}
data={data}
filtering={{ columns: filterColumns }}
/>;Filtering runs off each filter's own accessor, so a filtering.columns entry can
target a field without a visible column (e.g. a state filter with no
state column) — the table adds a hidden column for it. Provide filtering.options
for option filters whose values aren't derivable from a visible column.
.icon(...) is optional: when given, the icon shows in the filter chooser
and on the active-filter chip; when omitted you just get the label. option /
multiOption filters over plain strings or numbers work out of the box —
each value becomes { label: value, value }. Use .transformOptionFn() (or
static filtering.options) only when you need different labels, or when the accessor
returns objects.
For server-side filtering set filtering.strategy="server" and control the
state yourself — the table only manages the filter UI, you apply filters to
your query.
filtering.options is required here, for every option and multiOption
column: client-side the table reads a column's options off the rows, and
server-side those rows are one filtered answer rather than the domain — a list
built from them would offer only the values that survived the last filter. A
column left without them throws when its value popover is opened, and a dev
build warns about it as soon as the table renders:
const [filters, setFilters] = useState<FiltersState>([]);
<DataTable
columns={columns}
data={serverData}
filtering={{
columns: filterColumns,
strategy: "server",
value: filters,
onChange: setFilters,
options: { role: [{ label: "Engineering", value: "engineering" }] },
}}
/>;For server-side global search, pass search.onChange — the table stops filtering
data by the search text and hands you the query to run yourself. It is
already debounced (search.debounce, 300ms by default), so one request goes out
per pause rather than per keystroke. Optionally control the input with
search.value:
const [q, setQ] = useState("");
<DataTable
columns={columns}
data={serverData} // already filtered by your query
search={{
value: q,
onChange: (value) => {
setQ(value);
refetch(value); // already debounced by the table
},
}}
/>;Filter and grouping pills
An active filter and an active grouping each render as one button group, reading like the sentence it states, with every part its own control:
⟳ Status │ is │ ⟳ Backlog │ × ⟳ Status │ ↑ Ascending │ ×| The filter's segments | Clicking it |
| --------------------- | -------------------------------------------------- |
| the property | moves the filter to another column |
| the operator | is, is not, <, >, … for that column's type |
| the value | opens that column's value picker |
| × | removes the filter |
| The grouping's segments | Clicking it |
| ----------------------- | -------------------------------------- |
| the grouped column | groups by another column instead |
| the sort | Default, Ascending or Descending |
| × | removes the grouping |
Default is the order the data already has — no sort of the table's own. It is
a named choice rather than an absence, so a grouping you sorted can be put back;
the segment reads it out, and the menu marks which of the three is on.
This is the only rendering — there is no chip variant to switch back to. The
FILTERS / GROUPINGS labels beside the pills follow the column headers, and
classNames.filterPill / filterSegment (and their grouping twins) are what
restyle the pills themselves.
Moving a filter to another column takes two steps, because a filter cannot exist without a value: pick the new property, then pick its value. The filter you started from stays until that second step lands, so backing out of the popover leaves you where you were instead of silently dropping it.
Four classNames entries reach the pills, so a host can restyle them without
re-implementing them:
<DataTable
style={{
classNames: {
filterPill: "rounded-full", // the filter's button group
filterSegment: "px-3", // every segment inside it
groupingPill: "rounded-full", // the grouping's button group
groupingSegment: "px-3", // every segment inside it
},
}}
/>filtersBar and groupingsBar still style the rows the pills sit on. The pills
carry data-dt-slot="filter-pill" / "grouping-pill" for CSS and tests.
Each row ends in a + that adds the next filter or grouping, opening the
same menu the toolbar button does — the property list, then that column's value
picker for a filter, and the list of ungrouped columns for a grouping. It is a
menu of its own rather than a click forwarded to the toolbar: toolbar.menus
defaults to false, so a forwarding button would do nothing in exactly the tables
that have no other way to add a rule, and the value popover has to anchor to
whichever button you pressed.
And in a × that drops every rule on that row at once. It sits at the far
end of the bar rather than beside the +: one adds a rule and the other removes
all of them, and side by side they are a misclick apart.
A toolbar menu that is already doing something wears a dot in its top-right
corner — the meta bars say what, but they scroll out of sight and the button
does not. It carries data-dt-slot="menu-active-dot" and is aria-hidden: the
rules it stands for are in the bar underneath, and "active" on a button named
"Filter" is not something a screen reader user can act on.
Column Visibility
Hiding and showing columns
Controlled hidden column ids (uncontrolled if omitted — the toolbar menu still
works). Pair with columnVisibility.onChange to persist.
<DataTable
columns={columns}
data={data}
columnVisibility={{ hidden: ["assignee"] }}
/>Row Selection
Reading the selection
Selection (checkboxes and the X shortcut) is reported through
selection.onChange with the selected row objects already resolved:
const [selected, setSelected] = useState<Person[]>([]);
<DataTable
columns={columns}
data={data}
getRowId={(r) => r.id}
selection={{ onChange: (_selection, rows) => setSelected(rows) }}
/>;
{
selected.length > 0 && <BulkActionsBar rows={selected} />;
}Row Actions
Actions at the end of a row
Add per-row actions with rowActions: they render as icon buttons at the end
of the row (revealed on hover), with tooltips from label. Set inMenu: true
to collapse an action into a trailing "⋮" dropdown instead:
import { Copy, Pencil, Trash2 } from "lucide-react";
<DataTable
columns={columns}
data={data}
rowActions={[
{ label: "Edit", icon: Pencil, onClick: (r) => openEditor(r) },
{
label: "Duplicate",
icon: Copy,
inMenu: true,
onClick: (r) => duplicate(r),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive", // tinted red, both inline and in the menu
inMenu: true,
disabled: (r) => r.locked, // boolean or per-row predicate
onClick: (r) => remove(r),
},
]}
/>;Action clicks never trigger onRowClick. Right-clicking a row opens a
context menu listing all of its actions (inline and inMenu alike).
rowActions.in picks where they are reachable from:
| value | trailing column | right-click menu |
| ------------------ | --------------- | ---------------- |
| "both" (default) | ✅ | ✅ |
| "column" | ✅ | — |
| "menu" | — | ✅ |
// A Linear-style list: nothing at the end of the row, everything on right-click.
<DataTable
columns={columns}
data={data}
rowActions={{ items: actions, in: "menu" }}
/>"menu" doesn't just hide the column — it never mounts it, so its width goes
back to the real columns. inMenu still decides inline-button vs "⋮" inside
the column, and is irrelevant under "menu" (the context menu lists
everything). Also settable on DataTableProvider.
Actions can differ per row, not just their labels: pass a function and it
is called with each row. disabled can only grey an action out — this is what
leaves it out.
<DataTable
columns={columns}
data={data}
rowActions={(row) =>
row.status === "draft"
? [{ label: "Publish", icon: Upload, onClick: publish }]
: [
{ label: "Unpublish", icon: Download, onClick: unpublish },
{
label: "Delete",
icon: Trash2,
variant: "destructive",
inMenu: true,
onClick: remove,
},
]
}
/>The actions column is sized for the row that needs the most inline buttons, so its width doesn't change as you scroll.
Bulk actions
Pass selection.actions and the table shows a floating, bottom-centered bar
whenever rows are selected — count on the left, one icon button (with tooltip)
per action in the middle, and a ✕ to clear the selection on the right. It uses
the table's own selection state, so no extra wiring is needed (it works with
both controlled selection.value and the internal one).
import { MapPin, Trash2 } from "lucide-react";
<DataTable
columns={columns}
data={data}
getRowId={(r) => r.id}
selection={{
actions: [
{
label: "Delete",
icon: Trash2,
variant: "destructive", // tinted red
onClick: (rows, ids) => bulkDelete(ids),
},
{
label: "Change location",
icon: MapPin,
disabled: (rows) => rows.length < 2, // static bool or selection predicate
onClick: (rows, ids) => openLocationMen