build69
build69  /  Components  /  Data Display  /  DataTable

DataTable

v2.0.10Updated 20 Oct 2026

Professional sortable, filterable, groupable table powered by TanStack Table. Use for any tabular list where users need to slice the data.

Preview

Live · 57 stories
DefaultOpen ↗
▸Why this is recommendedtablelistgridsortable+27

The default, out-of-the-box DataTable.

Recommended for+14
  • ✓Listing invoices, customers, products, employees, transactions
  • ✓A wide register with many columns inside a page whose content width is capped, where the user needs a button to let the table borrow the rest of the screen for a moment
  • ✓Any dataset > 25 rows where users need sort / filter / group
  • ✓Admin dashboards requiring bulk operations
  • ✓Reporting tables that benefit from grouping by status / category
  • ✓Spreadsheet-like surfaces needing cell range copy or Excel export
  • ✓Tables where clicking a row expands an inline master-detail panel of related records
Tags+6
tablelistgridsortablefilterablepaginatedselectionexportimportcsvtanstackexcelmaster-detailreordercell-selectionstripedzebragrid-linesspreadsheeteditablefill-handlepastei18nlocalelabelsthaifield-typesemailtelcurrencydefault
Features+4
  • Column field types (DataTableColumn.type): email/tel/url render as links (url scheme-guarded to http(s)); number/currency/date/datetime format through the app locale (useB69Locale — Thai gets Buddhist years); boolean renders an icon. Right-aligns number/currency, centers boolean. An explicit `cell` always wins; filtering/sorting stay on the raw value.
  • Multi-column sorting (shift-click)
  • Per-column filtering (text, number, date, enum, boolean)
  • Global search (debounced, Cmd+K)
  • Per-column filters (text / number / date / enum / boolean) via header popovers, OR a left filter panel (enableFilterPanel) with a Filters toolbar button — one control per filterable column, faceted enum counts + number min/max, active-filter badge. The panel scrolls independently of the table (its own overflow, so a long filter list never grows the table row) and is drag- or keyboard-resizable between 200 and 560px. In page-flow mode (maxHeight="none") it sticks to the viewport instead, so it stays on screen and scrolls on its own while the rows scroll past — set --b69-filter-panel-sticky-top to your app bar height to clear it; it publishes its live width as --b69-filter-panel-width, and reports data-state="open"/"closed" on the data-table-filter-panel slot. It always opens at its first filter, and a short (one- or two-row) table no longer squeezes it: while open the panel+table row takes a floor of min(24rem, 60vh) (tunable with --b69-filter-panel-min-height), and in overlay mode the floating panel paints its own opaque surface, takes its own height capped to min(40rem, 85dvh) (--b69-filter-panel-overlay-max-height) rather than the table's, and stacks above the sticky header and pinned columns
  • Group by with per-group aggregates (sum, mean, min, max, count, unique)
  • Row selection with bulk actions bar
  • Icon-based row actions (getRowActions): inline quick-action icon buttons with tooltips + "⋯" overflow menu; danger tone, per-row disabled
  • Column visibility / pinning / resizing
  • Empty, loading and error states stay readable on a table that scrolls sideways: the message box is sized to the visible part of the table and sticks to its leading edge, so it stays centred in view at any horizontal scroll offset instead of centring on the full column width (fb_2574314f)
  • Every control the table owns reports its new state to the host, so per-user list preferences can be persisted server-side: onColumnVisibilityChange, onColumnPinningChange, onColumnOrderChange, onColumnSizingChange, onSortingChange, onGroupingChange, onColumnFiltersChange, onSearchChange, onPaginationChange, onExpandedChange, onDensityChange, onActiveViewChange, onRowPinningChange, onRowSelectionChange, onCellSelectionChange, onFilterPanelOpenChange / onFilterPanelWidthChange. Each of the first seven also accepts a controlled value (columnVisibility, columnPinning, columnOrder, columnSizing, sorting, grouping, columnFilters) — pass the callback alone to listen, or both to own the state. Omit them and the table keeps every piece of state itself, unchanged (fb_de40b58f)
  • Reorder the COLUMNS themselves by dragging their headers (enableColumnReorder) — this moves a column left or right; it is not dragging rows or cards between columns, which is Kanban
  • Cell range selection with Ctrl/Cmd+C copy-to-clipboard TSV (enableCellSelection); the write falls back to execCommand on an insecure origin and a refusal is reported as an error toast, never swallowed; the success toast has a separate singular label so one cell never reads "1 cells"
  • Row pinning sticky to top or bottom (enableRowPinning)
  • Excel export with per-column formatting (enableExcelExport, meta.excelFormat): bold header, per-column number / date / currency formats, column widths and a frozen header row
  • Excel export and CSV import carry NO third-party dependency: the .xlsx writer and the RFC 4180 CSV reader ship in the bundle (data-table-spreadsheet.ts). The xlsx / SheetJS package is deliberately not used — every version npm can install carries CVE-2023-30533 and CVE-2024-22363, both in the reader
  • Pivot mode (pivot prop): spreadsheet-style matrix with rows / columns / aggregated values
  • Pivot multi-level column headers + row / column / grand totals
  • Excel export of the pivot matrix
  • Inline cell edit
  • Density toggle (compact / normal / comfortable)
  • Sticky header, sticky pinned columns
  • Expand-to-viewport-width toggle (enableFullWidthToggle): a toolbar button that grows ONLY the table box to the full screen width and back, for a wide register inside a page whose content is capped. Nothing else on the page moves, nothing is remembered, and the state never reaches the preference callbacks — it is a momentary "let me see this", not a layout setting. The root reports data-expanded="yes"/"no"; the component measures --b69-table-inset itself so it needs no app CSS, and --b69-table-gutter (default 1.5rem) tunes the far-edge margin. Controllable with fullWidth / onFullWidthChange. MCP feedback fb_f26eab7f
  • A column name that is too long for its column is still readable: the header label is measured (ResizeObserver) and, only when it is actually clipped, the header reveals the full name in a tooltip on hover AND on keyboard focus. The trigger is the sort control, so the header keeps its accessible name, its sort click and its keyboard; a name that fits shows nothing. And the width floor a column can be dragged to is now derived from its own label rather than a flat 60px — see columns[].minSize. MCP feedback fb_f26eab7f
  • Zebra striping (striped prop) — frozen columns stripe too
  • Grid line / separator control (gridLines: none | horizontal | vertical | both)
  • Excel-style spreadsheet mode (spreadsheetMode): row-number gutter, active-cell keyboard nav, type-to-edit / F2, fill handle, paste-to-fill (Ctrl/Cmd+V), batched onCellsChange
  • CSV export of filtered + sorted rows
  • CSV import via toolbar (enableImport + onImport): quoted fields, embedded newlines, CRLF, BOM, a sep= directive and semicolon / tab separators; values reach onImport verbatim, and a header named __proto__ cannot pollute Object.prototype
  • Toolbar wraps to the width of its own container: at phone width the search takes a full row and the controls flow onto rows below, so the card never forces a sideways scroll (no viewport breakpoint involved, so a narrow pane on a wide screen wraps too)
  • MasterDetail expandable row content (masterDetail render prop)
  • View presets (save / restore filter + sort + group + visibility)
  • Keyboard navigation
  • Loading / empty / error states
  • Sub-rows / expandable hierarchy
  • i18n: labels prop overrides every built-in UI string (toolbar, pagination, selection/bulk bar, empty/error states, aria-labels); counts format through LocaleProvider (Thai numerals, Buddhist calendar apps)
Not recommended for−2
  • ✕Small lists (< 10 rows — use Table or KVList)
  • ✕A simple, plain, static table with no interactivity (use Table)
  • ✕Hierarchical tree data primary (use FileBrowser)
  • ✕Card-based gallery layouts (use a grid of Cards)

Install

Ask your agent, or call the MCP tool directly. The resolved bundle is written into your project.

get_registry_item({ name: "DataTable" })

Usage

import { DataTable } from '@/components/data-display/data-table';

<DataTable
  data={invoices}
  columns={[
    { id: 'number', header: 'Invoice', accessorKey: 'number' },
    { id: 'customer', header: 'Customer', accessorKey: 'customer' },
    { id: 'amount', header: 'Amount', accessorKey: 'amount', meta: { align: 'right', mono: true } },
  ]}
  rowSelection="multi"
  bulkActions={[{ id: 'send', label: 'Send', onClick: rows => sendAll(rows) }]}
/>

Tick variants in the left menu — each adds a tab with that variant’s real story source, the same code the preview above renders.

Props

PropTypeRequired
data

Array of row objects

T[]yes
columns

Column definitions

DataTableColumn<T>[]yes
rowSelection

Enable row selection

boolean | 'multi' | 'single'—
bulkActions

Selection toolbar actions

BulkAction<T>[]—
getRowActions

Per-row actions column. Mark an action `inline: true` (with an icon) to render it as an icon button with its label as tooltip (visible by default — see `rowActionsReveal`); the rest collect in a "⋯" overflow menu. `tone: 'danger'` for destructive styling, `disabled` grays out without hiding. Keep 2–3 inline, long tail in the menu.

(row: T) => DataTableRowAction[]—
rowActionsReveal

When the row-action icons show. 'always' (default) keeps them on screen — most discoverable, and the only option that works on touch. 'hover' reveals them on row hover / keyboard focus / open menu (denser Linear/Attio pattern, but invisible on touch).

'always' | 'hover'—
views

Saved view presets

DataTableView[]—
state

Top-level UI state

'loading' | 'empty' | 'error'—
onRowClick

Row click handler

function—
enableFilterPanel

Show a Filters button in the toolbar that toggles a left-hand filter panel, wired to the same column filters as the header popovers. Off by default. PREREQUISITE: the panel lists one control per column that declares `meta.filterType` ('text'|'number'|'date'|'enum'|'boolean') AND has a resolvable value (an accessorKey/accessorFn or matching id) — columns without a filterType are skipped, so enabling the prop on a table whose columns declare none renders an empty panel. Give each filterable column a meta.filterType.

boolean—
filterPanelMode

Placement of the filter panel (needs enableFilterPanel). 'rail' (default) is an inline sibling that takes 256px when open — best for a full-width table. 'overlay' floats the panel over the table's left edge with a dismiss backdrop so the table keeps its full width — use inside Sheets/drawers/narrow panes where a rail would crush the columns. 'auto' is rail on wide containers, overlay once the container narrows (<560px). Default 'rail' is byte-identical. MCP feedback fb_903212e0.

'rail' | 'overlay' | 'auto'—
filterPanelResizable

Let the user drag the filter panel's right edge to resize it. On by default — the handle is a transparent 6px strip that only paints on hover/focus, and the panel still opens at defaultFilterPanelWidth, so nothing moves until it is dragged. Keyboard-resizable too: the handle is a focusable ARIA separator (arrows nudge 8px, Shift+arrow 32px, Home/End snap to the bounds). Set false for a fixed-width panel.

boolean—
defaultFilterPanelWidth

Width (px) the filter panel opens at. Default 256 — the previous fixed width, so an untouched panel is unchanged.

number—
filterPanelWidth

Controlled filter-panel width (px). Pass with onFilterPanelWidthChange to own the width — e.g. to persist it per user. Clamped to filterPanelMinWidth/filterPanelMaxWidth on read, so a stored value can never render the panel out of bounds.

number—
onFilterPanelWidthChange

Fires on every resize step with the new clamped width (px). Use with filterPanelWidth for a controlled panel, or alone to persist an uncontrolled one.

function—
filterPanelMinWidth

Lower resize bound (px). Default 200 — below this the enum option rows wrap badly.

number—
filterPanelMaxWidth

Upper resize bound: px (default 560 — above this the panel crowds out the table it filters), or a CSS length such as '60vw', '70%' (of the panel+table row) or '40rem', which is resolved in the browser and re-resolved as the screen resizes — a screen-relative ceiling suits long (e.g. Thai) filter labels better than a fixed px. Until it is resolved (SSR, first paint) the 560px default applies, and it never resolves below filterPanelMinWidth. MCP feedback fb_222f783b.

number | string—
columns[].meta.headerHelp

Renders a "?" beside the column header explaining what the column means. A node gives a one-liner; { title, body } gives a heading plus detail. Composing this yourself needs a function `header`, which loses the plain-string header ergonomics (filter chips, the column picker and export all read `label`) and repeats on every column. The trigger is deliberately a SIBLING of the sort control, never a child: a button inside the sort button is invalid HTML and a React 19 SSR hydration error. On a column that opts in, the sort hit area shrinks from the whole header cell to the label. MCP feedback fb_54569a6f.

React.ReactNode | { title, body }—
columns[].minSize

Lower width bound (px) for this column, enforced on drag and on keyboard resize. DEFAULT: derived from the column's own NAME (its label plus the affordances that header renders — sort chevron, "?" help, drag handle), clamped to [60, 220]. It used to be a flat 60px, which is narrower than almost any real column name, so the default state of a resizable column was "your header is unreadable". Thai tone marks and vowel signs are not counted, since they advance the pen by zero. Set this to take the floor back under your own control. MCP feedback fb_f26eab7f.

number—
persistFilterPanelWidthKey

localStorage key that persists the UNCONTROLLED panel width across sessions. Ignored when filterPanelWidth is controlled. The stored value is adopted after mount (never during render), so it cannot cause a hydration mismatch. Best-effort — a storage failure in private mode is swallowed. MCP feedback fb_c860aaf0.

string—
defaultFilterPanelOpen

Whether the filter panel starts open. Default false (unchanged).

boolean—
filterPanelOpen

Controlled filter-panel open state. Pass with onFilterPanelOpenChange to own it — e.g. to restore whether the user left the panel open on their last visit. MCP feedback fb_4c48a1f5.

boolean—
onFilterPanelOpenChange

Fires whenever the panel opens or closes — the toolbar button, the panel close button, or the overlay dismiss backdrop. Use with filterPanelOpen for a controlled panel, or alone to persist an uncontrolled one.

function—
enableFullWidthToggle

Show a toolbar toggle that expands ONLY the table box to the viewport width and back (grid and pivot). Off by default. A momentary "let me see this" gesture, not a layout preference: the state is never stored and never reaches the preference callbacks, so a host that persists every preference can never restore a table expanded. Nothing else on the page moves — the table grows past its container towards the viewport's far edge, so the heading and the KPI cards above it stay put. Width: calc(100vw - var(--b69-table-inset) - var(--b69-table-gutter)). The component MEASURES --b69-table-inset (the gap between the viewport's leading edge and the table's own, i.e. your sidebar plus page padding) and seeds it, so this needs no app CSS; set it on any ancestor to pin an exact value, and --b69-table-gutter (default 1.5rem) for the far-edge breathing space. The root reports data-expanded="yes"/"no". Requirement: no ancestor may clip horizontally, or the extra width is cut off. MCP feedback fb_f26eab7f.

boolean—
fullWidth

Controlled expanded state. Pass with onFullWidthChange to drive the expansion from a button of your own; omit both and the built-in toggle owns it. Uncontrolled seed: defaultFullWidth.

boolean—
defaultFullWidth

Whether the uncontrolled table starts expanded. Default false.

boolean—
onFullWidthChange

Fires whenever the table expands or collapses. Deliberately NOT one of the persisted preference callbacks — the expansion is a gesture, so storing it is the one thing this prop is not for.

(expanded: boolean) => void—
enableColumnReorder

Drag-and-drop column reorder

boolean—
columnVisibility

Controlled column visibility ({ [columnId]: boolean }, false = hidden). Pass with onColumnVisibilityChange to own it; omit both and the table keeps the state internally, seeded by initialColumnVisibility.

VisibilityState—
onColumnVisibilityChange

Fires whenever a column is shown or hidden — the built-in Columns menu, its Reset entry, the toolbar Reset, or a saved view being applied. This is what lets an app persist a user's hidden columns instead of shipping a second column menu of its own (fb_de40b58f).

(visibility: VisibilityState) => void—
columnPinning

Controlled column pinning. The table re-adds its own system columns (selection, expand, row actions) to the edges on read, so a stored value that only lists the caller's columns can never unpin them.

{ left?: string[]; right?: string[] }—
onColumnPinningChange

Fires whenever a column is pinned or unpinned — the header pin menu, the header right-click cycle, or a view being applied.

(pinning: { left: string[]; right: string[] }) => void—
columnOrder

Controlled column order. Unknown ids are dropped and unmentioned columns keep their declared position, so a stored order survives a column being added or removed. Replaces the remount-with-a-key workaround an initialColumnOrder-only API forced.

string[]—
columnSizing

Controlled per-column widths in px, keyed by column id (what the resize handles write). Uncontrolled seed: initialColumnSizing.

Record<string, number>—
onColumnSizingChange

Fires when a column is resized by drag or keyboard, with the full sizing map.

(sizing: Record<string, number>) => void—
sorting

Controlled sorting (uncontrolled seed: initialSorting).

SortingState—
onSortingChange

Fires on header click / shift-click multi-sort / a view being applied.

(sorting: SortingState) => void—
grouping

Controlled grouping (uncontrolled seed: initialGrouping).

string[]—
onGroupingChange

Fires when the Group-by menu or a header Group-by entry changes the grouped columns.

(grouping: string[]) => void—
columnFilters

Controlled column filters (uncontrolled seed: initialColumnFilters).

ColumnFiltersState—
onColumnFiltersChange

Fires for every filter route — header popover, filter panel, clearing a chip, Clear filters.

(filters: ColumnFiltersState) => void—
initialColumnFilters

Filters the table starts with.

ColumnFiltersState—
initialColumnSizing

Column widths (px) the table starts with — e.g. the widths this user dragged last time.

Record<string, number>—
initialSearch

Value the toolbar search box starts with.

string—
onSearchChange

Fires on every keystroke in the toolbar search box (the raw value; filtering stays debounced 200ms inside). Callback-only — a controlled search box would round-trip every keystroke through the host.

(value: string) => void—
onPaginationChange

Fires when the page or the page size changes, including the automatic snap back to page 1 when the filtered row set changes. Callback-only; seed the size with initialPageSize.

(page: { pageIndex: number; pageSize: number }) => void—
onExpandedChange

Fires when rows expand or collapse — a group chevron, Expand all / Collapse all, a detail row. masterDetail.onExpandedChange reports the same event as row ids. Callback-only: master/detail exclusive mode owns this state's shape.

(expanded: ExpandedState) => void—
onActiveViewChange

Fires when a saved view is applied or saved (the view) or cleared by Reset (null).

(view: DataTableView | null) => void—
enableCellSelection

Cell range selection + copy TSV

boolean—
enableRowPinning

Pin rows to top / bottom

boolean—
enableExcelExport

Excel export action (uses meta.excelFormat)

boolean—
striped

Alternate row background (zebra striping); frozen columns stripe too

boolean—
gridLines

Separator display (default 'horizontal' = row dividers only)

'none' | 'horizontal' | 'vertical' | 'both'—
layout

Column width strategy. 'fit' fills the container — flexible columns (no explicit size, unpinned, not user-resized) share the remaining width so the table never overflows or clips a trailing column; explicit-size / system / pinned columns keep their widths (table-layout: fixed makes the <colgroup> authoritative). 'auto' (default) lets the table scroll when columns exceed the viewport, and there an explicit `size` is a guaranteed MINIMUM: it is emitted as a min-width on the cells, because in table-layout: auto a <col width> is only a hint the browser drops once the table is over-constrained — a 220px column measured 130px (fb_c5ab85e3). Columns with no `size` stay fully fluid in 'auto'. With no flexible column present 'fit' falls back to 'auto', floors included, so the two agree.

'auto' | 'fit'—
enableImport

Show a toolbar CSV Import button

boolean—
onImport

Receives parsed CSV records (keyed by header) on import

(rows: Record<string, string>[], file: File) => void—
spreadsheetMode

Excel-style mode: row-number gutter (a system column: no lock, pin menu, drag or resize handle), active-cell keyboard nav, type-to-edit/F2, fill handle, paste-to-fill (implies cell selection; editing needs editable columns)

boolean—
onCellsChange

Batched change callback for spreadsheet fill/paste/edits (falls back to onCellEdit per cell)

(changes: { rowId: string; columnId: string; value: unknown }[]) => void—
masterDetail

Render expandable detail content per row

(row: T) => ReactNode—
filteredEmptyState

Shown when active filters/search reduce a NON-empty dataset to zero rows (defaults to a "no matching rows" message). Distinct from emptyState — which is only shown when the dataset is genuinely empty — so a "create your first record" prompt never renders on a filtered-to-zero table.

React.ReactNode—
pivot

Render a spreadsheet-style pivot matrix from the data (multi-level column headers + row / column / grand totals + Excel export)

{ rows: string[]; columns: string[]; values: { field: string; aggregation: string; label?: string; formatter?: (v: number) => string }[]; showRowTotals?: boolean; showColumnTotals?: boolean; grandTotal?: boolean }—
enableViews

Show the saved-views control in the toolbar. Default true, which is what it always did — it was the one toolbar control with no switch, so a table with no views still offered the button (fb_438fd0e6).

boolean—
manualSorting

The rows arrive already ordered and a header click asks the SERVER for a different order. Pair with `sorting` + `onSortingChange`: the header keeps its affordance and its arrow, and the client-side sorter is not applied, so a page of server-sorted rows is no longer re-sorted among itself (fb_438fd0e6).

boolean—
rowCount

How many rows exist on the SERVER when this table holds one page. Without it the footer counts rows in the browser — 30 of 120 read "Showing 1–30 of 30". It also drives the page count, so the pager reaches every page rather than stopping at the end of the current fetch (fb_438fd0e6, fb_8e084154).

number—
labels

Override any built-in UI string — toolbar (search placeholder, density, Columns/Views/Group, Export), pagination ('Showing {range} of {total}', '{count} / page', '{page} / {pageCount}', the page-size select's accessible name), selection ('{count} selected', select-all/select-row aria), empty/error states, filter-panel controls (operator options and the match-type select's accessible name, value/min/max placeholders, option search, boolean Either/Yes/No, date-range prompt), row-actions and sort/pin/resize aria-labels, and the Ctrl/Cmd+C cell-copy toasts ('Copied {count} cells to clipboard' / 'Copy failed' + its hint). Strings with {token} placeholders are templates. Defaults: DEFAULT_DATA_TABLE_LABELS (English, exported). Wrap the app in LocaleProvider to also format all counts per locale (e.g. Thai numerals).

Partial<DataTableLabels>—

Related