npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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-css

Requires 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.css only in the app/route that renders the table; or
  • import @jacopozanti/data-table/tokens.css instead — identical tokens with no Tailwind source registration — and get the classes from the precompiled styles.css below.

| Entry point | Tokens | Utilities | Re-themeable | | ------------ | ------ | ----------------------------- | ---------------------- | | theme.css | ✅ | generated by your Tailwind | ✅ | | tokens.css | ✅ | none (pair with styles.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 fill column never goes below 160px, and an auto one 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;
  • loading draws 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: false used 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 with cellClassName was ignored and the control sat flush against the border. If you paired padded: false with a full-cell button (DataTableCellButton, or anything with h-full), change it to padded: "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…]
                                                              ↑ compresses

inline: 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.

password masks 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, and toCsv keeps its header while exporting nothing — but the value is still in the row object you passed, and a renderPreviewCell or renderGroupValue you 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 a renderCell of its own, which — a written renderer winning over the type's — meant a built column silently lost its type: date printed String(row.dueAt) and chips the 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 your accessorFn.

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 keystroke

Naming 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 column

false 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