DataTable
v2.0.10Updated 20 Oct 2026Professional sortable, filterable, groupable table powered by TanStack Table. Use for any tabular list where users need to slice the data.
Preview
Live · 57 stories▸Why this is recommendedtablelistgridsortable+27
The default, out-of-the-box DataTable.
- ✓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
- 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)
- ✕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.
Usage
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
dataArray of row objects
yescolumnsColumn definitions
yesrowSelectionEnable row selection
—bulkActionsSelection toolbar actions
—getRowActionsPer-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.
—rowActionsRevealWhen 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).
—viewsSaved view presets
—stateTop-level UI state
—onRowClickRow click handler
—enableFilterPanelShow 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.
—filterPanelModePlacement 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.
—filterPanelResizableLet 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.
—defaultFilterPanelWidthWidth (px) the filter panel opens at. Default 256 — the previous fixed width, so an untouched panel is unchanged.
—filterPanelWidthControlled 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.
—onFilterPanelWidthChangeFires on every resize step with the new clamped width (px). Use with filterPanelWidth for a controlled panel, or alone to persist an uncontrolled one.
—filterPanelMinWidthLower resize bound (px). Default 200 — below this the enum option rows wrap badly.
—filterPanelMaxWidthUpper 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.
—columns[].meta.headerHelpRenders 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.
—columns[].minSizeLower 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.
—persistFilterPanelWidthKeylocalStorage 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.
—defaultFilterPanelOpenWhether the filter panel starts open. Default false (unchanged).
—filterPanelOpenControlled 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.
—onFilterPanelOpenChangeFires 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.
—enableFullWidthToggleShow 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.
—fullWidthControlled 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.
—defaultFullWidthWhether the uncontrolled table starts expanded. Default false.
—onFullWidthChangeFires 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.
—enableColumnReorderDrag-and-drop column reorder
—columnVisibilityControlled column visibility ({ [columnId]: boolean }, false = hidden). Pass with onColumnVisibilityChange to own it; omit both and the table keeps the state internally, seeded by initialColumnVisibility.
—onColumnVisibilityChangeFires 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).
—columnPinningControlled 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.
—onColumnPinningChangeFires whenever a column is pinned or unpinned — the header pin menu, the header right-click cycle, or a view being applied.
—columnOrderControlled 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.
—columnSizingControlled per-column widths in px, keyed by column id (what the resize handles write). Uncontrolled seed: initialColumnSizing.
—onColumnSizingChangeFires when a column is resized by drag or keyboard, with the full sizing map.
—sortingControlled sorting (uncontrolled seed: initialSorting).
—onSortingChangeFires on header click / shift-click multi-sort / a view being applied.
—groupingControlled grouping (uncontrolled seed: initialGrouping).
—onGroupingChangeFires when the Group-by menu or a header Group-by entry changes the grouped columns.
—columnFiltersControlled column filters (uncontrolled seed: initialColumnFilters).
—onColumnFiltersChangeFires for every filter route — header popover, filter panel, clearing a chip, Clear filters.
—initialColumnFiltersFilters the table starts with.
—initialColumnSizingColumn widths (px) the table starts with — e.g. the widths this user dragged last time.
—initialSearchValue the toolbar search box starts with.
—onSearchChangeFires 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.
—onPaginationChangeFires 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.
—onExpandedChangeFires 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.
—onActiveViewChangeFires when a saved view is applied or saved (the view) or cleared by Reset (null).
—enableCellSelectionCell range selection + copy TSV
—enableRowPinningPin rows to top / bottom
—enableExcelExportExcel export action (uses meta.excelFormat)
—stripedAlternate row background (zebra striping); frozen columns stripe too
—gridLinesSeparator display (default 'horizontal' = row dividers only)
—layoutColumn 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.
—enableImportShow a toolbar CSV Import button
—onImportReceives parsed CSV records (keyed by header) on import
—spreadsheetModeExcel-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)
—onCellsChangeBatched change callback for spreadsheet fill/paste/edits (falls back to onCellEdit per cell)
—masterDetailRender expandable detail content per row
—filteredEmptyStateShown 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.
—pivotRender a spreadsheet-style pivot matrix from the data (multi-level column headers + row / column / grand totals + Excel export)
—enableViewsShow 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).
—manualSortingThe 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).
—rowCountHow 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).
—labelsOverride 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).
—