# Data Table

> The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.1.0
- Install: `npx wingo-ui@latest add data-table`
- Docs: https://wingo-ui.com/components/data-table

## Interaction

Type in the search to filter every searchable column at once (accents do not matter, so "cafe" finds Café and "12480" finds $12,480.00). Press a quick filter chip to toggle it; applied column filters show as removable chips with Clear filters. Click a header to sort (text A to Z first, numbers and dates biggest or newest first, a third click clears it) and Shift-click to add a second sort. The chevron on a header opens its menu: sort, filter (checkboxes for statuses and cities, a text box, a number range or a date range), pin left or right, move left or right, hide. Drag a header sideways to reorder columns; with columnResizing drag its edge (double-click resets, or focus the edge and use the arrows). The Columns panel shows and hides columns, reorders them by dragging the handle or with the arrow buttons, switches density and resets the layout. With selection on, click the checkbox (Shift-click selects a range) or the row; the toolbar turns into the bulk bar with its actions, and when the whole page is selected a banner offers every matching row. Rows can expand into a detail panel with the chevron (or a click anywhere on the row when it has no onRowClick and no selection). The ⋯ button and a right-click open the row's actions. Keyboard: the header's sort buttons and menus come first in the Tab order, then the rows are one tab stop (whenever they can be selected, opened, expanded or have actions): Up / Down, Home / End and PageUp / PageDown move between them, Space selects (Shift + Space or Shift + arrows extend), Ctrl/Cmd + A selects all, Escape clears the selection, Enter opens the row (or expands it when that is all it does), Right / Left expand and collapse, Shift+F10 or the context menu key opens the row menu. On a phone every row is a card: long press a card to start selecting, then tap to add more while the bulk bar floats at the bottom; sort, filters and row menus open as bottom sheets, and Load more appends the next rows. 10,000 rows scroll at 60fps because only the visible ones are in the DOM.

## Installation

```bash
npx wingo-ui@latest add data-table
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/data-table` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/data-table.tsx`

npm dependencies: `npm i @tanstack/react-table @tanstack/react-virtual @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Context Menu](https://wingo-ui.com/components/context-menu) ([Markdown](https://wingo-ui.com/components/context-menu.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Date Range Picker](https://wingo-ui.com/components/date-range-picker) ([Markdown](https://wingo-ui.com/components/date-range-picker.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [useSelection](https://wingo-ui.com/components/use-selection) ([Markdown](https://wingo-ui.com/components/use-selection.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [useLongPress](https://wingo-ui.com/components/use-long-press) ([Markdown](https://wingo-ui.com/components/use-long-press.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Download, Mail, Pencil, Plus, Trash2 } from "lucide-react"
import { DataTable, createColumns } from "@/components/ui/data-table"

type Client = { id: string; name: string; city: string; status: string; balance: number; lastInvoice: string | null }

const col = createColumns<Client>()
const columns = col([
  col.accessor("name", { header: "Client", filter: "text" }),
  col.accessor("city", { header: "City", filter: "multi-select" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Active: { tone: "success" }, Overdue: { tone: "danger" }, Prospect: { tone: "info" } },
    filter: "multi-select",
  }),
  col.accessor("balance", { header: "Balance", type: "currency", footer: "sum", filter: "number-range" }),
  col.accessor("lastInvoice", { header: "Last invoice", type: "relative" }),
])

export function Clients({ clients }: { clients: Client[] }) {
  return (
    <DataTable
      data={clients}
      columns={columns}
      caption="Clients"
      locale="en-US"
      selectionMode="multiple"
      searchPlaceholder="Search clients"
      quickFilters={[{ id: "overdue", label: "Overdue", columnFilter: { id: "status", value: ["Overdue"] } }]}
      toolbarActions={[{ label: "New client", icon: <Plus />, primary: true }]}
      bulkActions={(rows) => [
        { label: "Send email", icon: <Mail /> },
        { label: "Export", icon: <Download />, onSelect: () => console.log(rows) },
        { label: "Delete", icon: <Trash2 />, tone: "danger" },
      ]}
      rowActions={(row) => [{ label: "Edit", icon: <Pencil />, onSelect: () => console.log(row.id) }]}
      persistKey="clients"
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `T[]` |  | The rows. In server mode only the current page. |
| `columns` | `DataTableColumn<T>[]` |  | Each column: id, header, accessor, cell, type (text, number, currency, percent, date, datetime, relative, badge, boolean, avatar), badge map, align, width / minWidth / maxWidth, sortable, sortingFn, filter (text, select, multi-select, number-range, date-range), filterOptions, searchable, hideable, pinned, resizable, footer (sum, avg, count, min, max, a node or a function), mobile role, headerTooltip, truncate. Build them typed with createColumns<T>(). |
| `getRowId?` | `(row: T) => string` |  | The row's key for selection and expansion (default row.id). Ids must be unique; duplicates print a dev warning. |
| `caption?` | `string` | `""` | The table's name for screen readers; it also names the card list on phones. Always pass one. |
| `searchPlaceholder?` | `string` | `"Search"` | The toolbar search's placeholder and accessible name. The playground shows its own hint while it holds the default. |
| `locale?` | `string` | `""` | Numbers, money, dates and relative times ("$12,480.00", "6 days ago"). Empty uses the UIProvider's, then the browser's; the playground falls back to en-US. |
| `labels?` | `Partial<DataTableLabels>` |  | Every string, English by default (DEFAULT_DATA_TABLE_LABELS): search, columns, densities, rows per page, select all / page / row, the select-all banner, sort / filter / pin / move / hide, clear filters, empty and no results, error and retry, expand / collapse, drag announcements, plus toolbar and pagination strings passed through. |
| `mode?` | `"client" \| "server"` | `"client"` | client sorts, filters and pages in the browser. server does none of it locally: data is the current page, rowCount the total, and every change reaches onQueryChange (the page goes back to 1 when the search, a filter or the sort changes). |
| `rowCount?` | `number` |  | Server mode: the total number of matching rows (default data.length). The last page clamps to it. |
| `onQueryChange?` | `(query: DataTableQuery) => void` |  | Server mode: { page, pageSize, sorting, globalFilter, columnFilters } whenever one of them changes (the search arrives debounced, 300ms). Set refreshing while you fetch. |
| `pagination?` | `"pages" \| "load-more" \| "infinite" \| "none"` | `"pages"` | pages: numbered pages with a rows-per-page picker (compact on phones). load-more: a button. infinite: loads when the end comes into view (with onLoadMore and hasMore for your own fetching). none: every row, best with virtualize. |
| `page?` | `number` |  | The current page, 1-based, controlled. Pair it with onPageChange. |
| `defaultPage?` | `number` | `1` | The starting page while uncontrolled (clamped to the last page). |
| `onPageChange?` | `(page: number) => void` |  | Called with the new page. |
| `pageSize?` | `number` |  | Rows per page, controlled. Also the step of load-more. |
| `defaultPageSize?` | `number` | `"25"` | Rows per page while uncontrolled (remembered with persistKey). |
| `onPageSizeChange?` | `(pageSize: number) => void` |  | Called when the rows-per-page picker changes. |
| `pageSizeOptions?` | `number[]` | `"[10, 25, 50, 100]"` | The choices in the rows-per-page picker. |
| `hasMore?` | `boolean` |  | load-more / infinite with your own fetching: whether more rows exist. |
| `onLoadMore?` | `() => (void \| Promise<unknown>)` |  | load-more / infinite with your own fetching: append the next rows to data. Without it the table reveals its own data a page at a time. |
| `loadingMore?` | `boolean` | `false` | load-more / infinite: the next rows are on their way (the button shows a spinner). |
| `sorting?` | `{ id: string; desc: boolean }[]` |  | The sort, controlled (TanStack's SortingState). Shift-click a header to add a column. |
| `defaultSorting?` | `{ id: string; desc: boolean }[]` | `"[]"` | The starting sort while uncontrolled. |
| `onSortingChange?` | `(sorting: DataTableSort[]) => void` |  | Called with the new sort. |
| `globalFilter?` | `string` |  | The search, controlled. Client mode matches every word over the searchable columns' text, formatted values included, ignoring case and diacritics. |
| `defaultGlobalFilter?` | `string` | `""` | The starting search while uncontrolled. |
| `onGlobalFilterChange?` | `(value: string) => void` |  | Called with the debounced search. |
| `columnFilters?` | `{ id: string; value: unknown }[]` |  | Column filters, controlled. Values: text a string, select a string, multi-select a string[], number-range [min, max] (either may be null), date-range { from, to }. |
| `defaultColumnFilters?` | `{ id: string; value: unknown }[]` | `"[]"` | The starting filters while uncontrolled. |
| `onColumnFiltersChange?` | `(filters: DataTableColumnFilter[]) => void` |  | Called with the new filters. |
| `quickFilters?` | `DataTableQuickFilter<T>[]` |  | Toggle chips in the toolbar: { id, label, icon?, count?, filter: (row) => boolean } (client) or { id, label, columnFilter: { id, value } } (both modes; chips on the same column act like a radio). |
| `columnVisibility?` | `Record<string, boolean>` |  | Which columns show, controlled (false hides). |
| `defaultColumnVisibility?` | `Record<string, boolean>` | `"{}"` | The starting visibility, e.g. { createdAt: false }. Reset in the Columns panel returns here. |
| `onColumnVisibilityChange?` | `(visibility: Record<string, boolean>) => void` |  | Called when a column is shown or hidden. |
| `columnOrder?` | `string[]` |  | Column ids in display order, controlled. Unknown ids are dropped and new columns appended, so a saved order survives column changes. |
| `defaultColumnOrder?` | `string[]` | `"[]"` | The starting order while uncontrolled; empty follows columns. |
| `onColumnOrderChange?` | `(order: string[]) => void` |  | Called after a drag, a Move left / right or a Move up / down. |
| `selectionMode?` | `"none" \| "single" \| "multiple"` | `"none"` | Row selection with checkboxes (and long press on phone cards). multiple adds the header box, ranges, Ctrl/Cmd + A and the bulk bar. |
| `selectedKeys?` | `string[]` |  | Selected row ids, controlled. They survive paging and filtering: rows filtered out stay selected and the banner says how many are hidden. |
| `defaultSelectedKeys?` | `string[]` | `"[]"` | The starting selection while uncontrolled. |
| `onSelectedKeysChange?` | `(keys: string[]) => void` |  | Called with the selected ids in display order. |
| `selectAllScope?` | `"page" \| "all"` | `"page"` | page: the header box selects the page and a banner offers every matching row (Gmail). all: it selects every row that passes the filters at once (client mode). |
| `bulkActions?` | `(rows: T[], keys: string[]) => ToolbarAction[]` |  | The toolbar's bulk mode while rows are selected: an inline band on desktop, a floating bar on phones. A danger tone stays red; a returned promise shows the button loading. |
| `rowActions?` | `(row: T) => MenuEntry[]` |  | A ⋯ column pinned to the end (revealed on hover and focus, always on touch; a sheet on phones), the same menu on right-click, and Shift+F10 on a focused row. |
| `onRowClick?` | `(row: T) => void` |  | Makes the row the target (Enter on a focused row too). With selection, Shift / Ctrl / Cmd + click selects instead. |
| `expandable?` | `boolean \| ((row: T) => boolean)` | `false` | A chevron column that opens renderSubRow under the row (Right / Left arrows too); a function decides per row. |
| `renderSubRow?` | `(row: T) => ReactNode` |  | The expanded panel, full width, sliding open with the collapse preset. |
| `expandedKeys?` | `string[]` |  | Expanded row ids, controlled. |
| `defaultExpandedKeys?` | `string[]` | `"[]"` | The rows expanded at first. |
| `onExpandedKeysChange?` | `(keys: string[]) => void` |  | Called when a row expands or collapses. |
| `toolbar?` | `boolean` | `true` | The toolbar above the table: search, quick filters, the Filters and Columns panels, page actions and the bulk bar. |
| `toolbarActions?` | `ToolbarAction[]` |  | Page actions; the primary one stays visible, the rest fold into ⋯ as space runs out. |
| `viewOptions?` | `boolean` | `true` | The Columns panel: show and hide columns, drag or Move up / down to reorder, density, Reset. |
| `columnMenu?` | `boolean` | `true` | The menu on each header: Sort ascending / descending, Clear sort, Filter, Pin left / right / Unpin, Move left / right, Hide. |
| `columnReorder?` | `boolean` | `true` | Drag headers sideways to reorder them (desktop). The header lifts into a floating chip while it moves. |
| `columnResizing?` | `boolean` | `false` | Drag handles on the header edges (fixed layout); double-click resets, arrows resize a focused handle. Widths are remembered with persistKey. |
| `density?` | `"compact" \| "default" \| "comfortable"` |  | Row height, controlled: 36 / 48 / 56px. |
| `defaultDensity?` | `"compact" \| "default" \| "comfortable"` | `"default"` | The starting row height while uncontrolled; the Columns panel switches it and persistKey remembers it. |
| `onDensityChange?` | `(density: DataTableDensity) => void` |  | Called when the density changes. |
| `variant?` | `"plain" \| "surface"` | `"surface"` | surface: a raised panel. plain: no panel, a header band, for tables inside cards. |
| `striped?` | `boolean` | `false` | A faint tint on every other row. |
| `stickyHeader?` | `boolean` | `true` | The header sticks under --table-sticky-top (default var(--topbar-offset, 0px)) while the page scrolls, or at the top of maxHeight. |
| `stickyFirstColumn?` | `boolean` | `false` | Pins the selection column and the first data column while the table scrolls sideways; a hairline shadow shows once rows slide under. |
| `maxHeight?` | `number \| string` | `0` | The table scrolls inside this height in px (and virtualizes against it); 0 or empty lets the page scroll. |
| `virtualize?` | `boolean \| "auto"` | `"auto"` | Renders only the rows in view (overscan 10) with spacer rows, so the <table> stays semantic. auto turns on past 200 rendered rows. Phone cards use the page's scroll. |
| `persistKey?` | `string` | `""` | Remembers column visibility, order, widths, density and page size in local storage under this key. |
| `loading?` | `boolean` | `false` | The first load: skeleton rows in the real column layout (skeleton cards on phones). |
| `skeletonCount?` | `number` | `8` | How many skeleton rows the first load shows. |
| `refreshing?` | `boolean` | `false` | Keeps the rows and slides a thin indeterminate bar along the top edge; sets aria-busy. |
| `error?` | `ReactNode` | `""` | Replaces the rows with the error state and this text; pair it with onRetry. |
| `onRetry?` | `() => (void \| Promise<unknown>)` |  | The error state's Try again; a returned promise shows it loading. |
| `emptyState?` | `ReactNode` |  | Shown when there is no data at all (default an EmptyState with labels.empty). |
| `showHeaderWhenEmpty?` | `boolean` | `false` | With no rows to show (no data, or a search that matches nothing) the table renders the empty state alone, compact, without column headers spread over nothing. Turn it on to keep the headers above the empty state. |
| `noResultsState?` | `ReactNode` |  | Shown when the search or filters hide every row (default the no-results EmptyState with Clear filters). |
| `mobileLayout?` | `"cards" \| "scroll"` | `"cards"` | Below 768px: a card per row (title, subtitle, a badge or amount, up to four fields), or the table scrolling sideways. |
| `renderCard?` | `(row: T) => ReactNode` |  | Your own phone card instead of the column mapping. |
| `mobilePagination?` | `"pages" \| "load-more"` | `"load-more"` | How phones page through pages mode: Load more appends (the default, it feels native), pages keeps the compact pager. Server mode always pages. |
| `classNames?` | `Partial<Record<"toolbar" \| "banner" \| "table" \| "scroll" \| "header" \| "head" \| "body" \| "row" \| "cell" \| "selectCell" \| "actionsCell" \| "subRow" \| "footer" \| "empty" \| "cards" \| "card" \| "pagination", string>>` |  | Extra classes for the inner parts. |

## Parts

### createColumns (function)

A typed column builder: accessor(key) checks the key against T, computed(id, fn) derives a value, display(id) only renders. Signature: <T>() => ((columns) => DataTableColumn<T>[]) & { accessor(key, column), computed(id, fn, column), display(id, column) }.

### useDataTable (hook)

The headless part: builds the TanStack v9 table from the same props (features registered once at module scope) and returns the rows to render, the useSelection result, the state and the actions, for custom renderings. Signature: (options: DataTableOptions<T>) => { table, rows, phoneRows, filteredRows, selection, state, columns, actions, loaded }.

### DEFAULT_DATA_TABLE_LABELS (constant)

The English strings; spread it to translate a few. Type: DataTableLabels.

## Examples

- **Server mode**: The same 1,240 clients behind a fake API with a 600ms delay: sorting, search and filters go out through onQueryChange while the bar shows refreshing.
- **10,000 rows, virtualized**: pagination="none", virtualize, maxHeight 520 and density="compact": the DOM holds only the rows on screen plus overscan.
- **Expandable invoices**: Each invoice expands to its lines, a nested Table; Right and Left arrows expand and collapse the focused row.
- **Wide table**: Twelve columns with the first one pinned, drag-to-resize edges and persistKey: reload the page and your columns stay.
- **Empty, no results, error**: No data at all, a search for zzz that matches nothing (with Clear filters), and a failed load with Try again.
- **On a phone**: Cards instead of rows: long press a card to start selecting, tap to add more, the bulk bar floats at the bottom and Load more appends.
- **Minimal**: toolbar={false}, five rows, no pagination and variant="plain" inside a Card.

## See also

- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Virtual List](https://wingo-ui.com/components/virtual-list) ([Markdown](https://wingo-ui.com/components/virtual-list.md))

---

Canonical: https://wingo-ui.com/components/data-table
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
