Data Table React component for Tailwind CSS v4
v1.1.0Updated in 1.2.0
A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.
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.
Data Table is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add data-table, together with Table, Progress, Empty State, Toolbar, Pagination and 27 more and the npm packages @tanstack/react-table, @tanstack/react-virtual, @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add data-tableData Table is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import DataTable from @/components/ui/data-table.
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
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) => stringThe row's key for selection and expansion (default row.id). Ids must be unique; duplicates print a dev warning.
captiondefault""stringThe table's name for screen readers; it also names the card list on phones. Always pass one.
searchPlaceholderdefault"Search"stringThe toolbar search's placeholder and accessible name. The playground shows its own hint while it holds the default.
localedefault""stringNumbers, 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.
labelsPartial<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.
modedefault"client""client" | "server"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).
rowCountnumberServer mode: the total number of matching rows (default data.length). The last page clamps to it.
onQueryChange(query: DataTableQuery) => voidServer mode: { page, pageSize, sorting, globalFilter, columnFilters } whenever one of them changes (the search arrives debounced, 300ms). Set refreshing while you fetch.
paginationdefault"pages""pages" | "load-more" | "infinite" | "none"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.
pagenumberThe current page, 1-based, controlled. Pair it with onPageChange.
defaultPagedefault1numberThe starting page while uncontrolled (clamped to the last page).
onPageChange(page: number) => voidCalled with the new page.
pageSizenumberRows per page, controlled. Also the step of load-more.
defaultPageSizedefault"25"numberRows per page while uncontrolled (remembered with persistKey).
onPageSizeChange(pageSize: number) => voidCalled when the rows-per-page picker changes.
pageSizeOptionsdefault"[10, 25, 50, 100]"number[]The choices in the rows-per-page picker.
hasMorebooleanload-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.
loadingMoredefaultfalsebooleanload-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.
defaultSortingdefault"[]"{ id: string; desc: boolean }[]The starting sort while uncontrolled.
onSortingChange(sorting: DataTableSort[]) => voidCalled with the new sort.
globalFilterstringThe search, controlled. Client mode matches every word over the searchable columns' text, formatted values included, ignoring case and diacritics.
defaultGlobalFilterdefault""stringThe starting search while uncontrolled.
onGlobalFilterChange(value: string) => voidCalled 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 }.
defaultColumnFiltersdefault"[]"{ id: string; value: unknown }[]The starting filters while uncontrolled.
onColumnFiltersChange(filters: DataTableColumnFilter[]) => voidCalled with the new filters.
quickFiltersDataTableQuickFilter<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).
columnVisibilityRecord<string, boolean>Which columns show, controlled (false hides).
defaultColumnVisibilitydefault"{}"Record<string, boolean>The starting visibility, e.g. { createdAt: false }. Reset in the Columns panel returns here.
onColumnVisibilityChange(visibility: Record<string, boolean>) => voidCalled when a column is shown or hidden.
columnOrderstring[]Column ids in display order, controlled. Unknown ids are dropped and new columns appended, so a saved order survives column changes.
defaultColumnOrderdefault"[]"string[]The starting order while uncontrolled; empty follows columns.
onColumnOrderChange(order: string[]) => voidCalled after a drag, a Move left / right or a Move up / down.
selectionModedefault"none""none" | "single" | "multiple"Row selection with checkboxes (and long press on phone cards). multiple adds the header box, ranges, Ctrl/Cmd + A and the bulk bar.
selectedKeysstring[]Selected row ids, controlled. They survive paging and filtering: rows filtered out stay selected and the banner says how many are hidden.
defaultSelectedKeysdefault"[]"string[]The starting selection while uncontrolled.
onSelectedKeysChange(keys: string[]) => voidCalled with the selected ids in display order.
selectAllScopedefault"page""page" | "all"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) => voidMakes the row the target (Enter on a focused row too). With selection, Shift / Ctrl / Cmd + click selects instead.
expandabledefaultfalseboolean | ((row: T) => boolean)A chevron column that opens renderSubRow under the row (Right / Left arrows too); a function decides per row.
renderSubRow(row: T) => ReactNodeThe expanded panel, full width, sliding open with the collapse preset.
expandedKeysstring[]Expanded row ids, controlled.
defaultExpandedKeysdefault"[]"string[]The rows expanded at first.
onExpandedKeysChange(keys: string[]) => voidCalled when a row expands or collapses.
toolbardefaulttruebooleanThe toolbar above the table: search, quick filters, the Filters and Columns panels, page actions and the bulk bar.
toolbarActionsToolbarAction[]Page actions; the primary one stays visible, the rest fold into ⋯ as space runs out.
viewOptionsdefaulttruebooleanThe Columns panel: show and hide columns, drag or Move up / down to reorder, density, Reset.
columnMenudefaulttruebooleanThe menu on each header: Sort ascending / descending, Clear sort, Filter, Pin left / right / Unpin, Move left / right, Hide.
columnReorderdefaulttruebooleanDrag headers sideways to reorder them (desktop). The header lifts into a floating chip while it moves.
columnResizingdefaultfalsebooleanDrag 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.
defaultDensitydefault"default""compact" | "default" | "comfortable"The starting row height while uncontrolled; the Columns panel switches it and persistKey remembers it.
onDensityChange(density: DataTableDensity) => voidCalled when the density changes.
variantdefault"surface""plain" | "surface"surface: a raised panel. plain: no panel, a header band, for tables inside cards.
stripeddefaultfalsebooleanA faint tint on every other row.
stickyHeaderdefaulttruebooleanThe header sticks under --table-sticky-top (default var(--topbar-offset, 0px)) while the page scrolls, or at the top of maxHeight.
stickyFirstColumndefaultfalsebooleanPins the selection column and the first data column while the table scrolls sideways; a hairline shadow shows once rows slide under.
maxHeightdefault0number | stringThe table scrolls inside this height in px (and virtualizes against it); 0 or empty lets the page scroll.
virtualizedefault"auto"boolean | "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.
persistKeydefault""stringRemembers column visibility, order, widths, density and page size in local storage under this key.
loadingdefaultfalsebooleanThe first load: skeleton rows in the real column layout (skeleton cards on phones).
skeletonCountdefault8numberHow many skeleton rows the first load shows.
refreshingdefaultfalsebooleanKeeps the rows and slides a thin indeterminate bar along the top edge; sets aria-busy.
errordefault""ReactNodeReplaces 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.
emptyStateReactNodeShown when there is no data at all (default an EmptyState with labels.empty).
showHeaderWhenEmptydefaultfalsebooleanWith 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.
noResultsStateReactNodeShown when the search or filters hide every row (default the no-results EmptyState with Clear filters).
mobileLayoutdefault"cards""cards" | "scroll"Below 768px: a card per row (title, subtitle, a badge or amount, up to four fields), or the table scrolling sideways.
renderCard(row: T) => ReactNodeYour own phone card instead of the column mapping.
mobilePaginationdefault"load-more""pages" | "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.
classNamesPartial<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
functionA 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
hookThe 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
constantThe 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.
Dependencies
Files
- components/ui/data-table.tsx
FAQ
Is the React Data Table component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Data Table?
Run npx wingo-ui@latest add data-table. It also adds the Wingo UI items it builds on (Table, Progress, Empty State, Toolbar, Pagination and 27 more) and the npm packages @tanstack/react-table, @tanstack/react-virtual, @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/data-table.
How does Data Table behave on phones?
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.
See also
Used in
More React table and list components
Articles using this component
- Antigravity MCP Setup: The Right Config Path and Prompts
Building UI with AI agents10 min read
- API Key Management UI: Create, Show Once, Roll, Revoke
Build it: screens and apps10 min read
- Audit Log UI in React: Filters, Diffs and CSV Export
Component guides10 min read
- Is shadcn Free for Commercial Use? MIT vs Paid Kits
Comparisons and alternatives10 min read