Data display
Data tablePro
A full data grid on TanStack Table v9: sorting, search, faceted filters with chips, column visibility, row selection with a bulk action bar, pagination, and loading and empty states.
npx prodular-ui add data-tablePart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Invoices
Search, Status and Owner filters with live counts, Plan under Add filter, sortable headers, row actions and a bulk bar that appears when rows are selected. Shift-click selects a range.
| INV-2095 | Meridian Freightinvoices@meridianfreight.co | Draft | HSHana Sato | Nov 20, 2026 | $622.00 | ||
| INV-2079 | Orchard Lane Grocersap@orchardlane.store | Open | TLTheo Lindqvist | Nov 15, 2026 | $2,394.00 | ||
| INV-2096 | Halcyon Roboticsap@halcyonrobotics.ai | Paid | PRPriya Raman | Nov 11, 2026 | $11,227.00 | ||
| INV-2057 | Ironclad Securityfinance@ironclad.sh | Paid | HSHana Sato | Nov 11, 2026 | $18,647.00 | ||
| INV-2066 | Fable & Finchhello@fableandfinch.com | Open | TLTheo Lindqvist | Nov 9, 2026 | $56,324.00 | ||
| INV-2076 | Northwind Logisticsbilling@northwind.io | Paid | PRPriya Raman | Nov 8, 2026 | $4,426.00 | ||
| INV-2069 | Lumen Analyticsbilling@lumenanalytics.io | Open | DFDaniel Ferreira | Nov 7, 2026 | $3,591.00 | ||
| INV-2090 | Fable & Finchhello@fableandfinch.com | Open | PRPriya Raman | Nov 6, 2026 | $37,779.00 | ||
| INV-2072 | Halcyon Roboticsap@halcyonrobotics.ai | Open | HSHana Sato | Nov 3, 2026 | $22,949.00 | ||
| INV-2092 | Pinecrest Schoolsfinance@pinecrest.edu | Open | HSHana Sato | Oct 27, 2026 | $495.00 |
1โ10 of 46
Page 1 of 5
"use client"
import * as React from "react"import { CopyIcon, DownloadIcon, FileTextIcon, MailIcon, CircleCheckIcon, Trash2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"import { Button } from "@/components/ui/button"import { DataTable, DataTableBulkActions, DataTableColumnHeader, DataTableContent, DataTableFilterBar, DataTableFilterChips, DataTablePagination, DataTableRowActions, DataTableSearch, DataTableToolbar, DataTableToolbarSpacer, DataTableViewOptions, createDataTableColumnHelper, dataTableSelectColumn, useDataTable, type DataTableFilterConfig,} from "@/components/ui/data-table"import { DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"import { Status, StatusIndicator, type StatusVariant } from "@/components/ui/status"
type InvoiceStatus = "paid" | "open" | "overdue" | "draft" | "void"type Invoice = { id: string customer: string email: string plan: "Starter" | "Growth" | "Scale" | "Enterprise" status: InvoiceStatus owner: string issued: Date due: Date amount: number}
const owners = [ { name: "Maya Okafor", initials: "MO" }, { name: "Theo Lindqvist", initials: "TL" }, { name: "Priya Raman", initials: "PR" }, { name: "Daniel Ferreira", initials: "DF" }, { name: "Hana Sato", initials: "HS" },]
const customers: [string, string][] = [ ["Northwind Logistics", "billing@northwind.io"], ["Kestrel Health", "ap@kestrelhealth.com"], ["Brightline Studio", "finance@brightline.studio"], ["Ostara Foods", "accounts@ostarafoods.com"], ["Halcyon Robotics", "ap@halcyonrobotics.ai"], ["Meridian Freight", "invoices@meridianfreight.co"], ["Cobalt Ridge Capital", "ops@cobaltridge.vc"], ["Lumen Analytics", "billing@lumenanalytics.io"], ["Pinecrest Schools", "finance@pinecrest.edu"], ["Tidewater Energy", "ap@tidewater.energy"], ["Fable & Finch", "hello@fableandfinch.com"], ["Granite Peak Outdoors", "billing@granitepeak.shop"], ["Verity Legal", "accounts@veritylegal.law"], ["Sundial Hospitality", "ap@sundialhotels.com"], ["Arcwell Medical", "billing@arcwellmedical.com"], ["Quill & Ink Press", "finance@quillink.press"], ["Riverbend Credit Union", "vendors@riverbendcu.org"], ["Helio Solar Co.", "ap@heliosolar.co"], ["Mosaic Learning", "billing@mosaiclearning.app"], ["Ironclad Security", "finance@ironclad.sh"], ["Bluefin Marine", "accounts@bluefinmarine.com"], ["Orchard Lane Grocers", "ap@orchardlane.store"], ["Parallax Games", "billing@parallax.gg"], ["Summit Dental Group", "office@summitdental.care"],]
const plans = [ { plan: "Starter", base: 49 }, { plan: "Growth", base: 490 }, { plan: "Scale", base: 1890 }, { plan: "Enterprise", base: 7200 },] as const
// Deterministic pseudo random numbers, so server and client render the same rows.function seeded(seed: number) { return () => { seed = (seed * 16807) % 2147483647 return (seed - 1) / 2147483646 }}
const today = new Date(2026, 9, 21)const day = 24 * 60 * 60 * 1000
function makeInvoices(): Invoice[] { const rand = seeded(42) return Array.from({ length: 46 }, (_, i) => { const [customer, email] = customers[i % customers.length] const tier = plans[Math.floor(rand() * plans.length)] const issued = new Date(today.getTime() - Math.floor(rand() * 75) * day) const due = new Date(issued.getTime() + 30 * day) const r = rand() let status: InvoiceStatus = r < 0.08 ? "draft" : r < 0.12 ? "void" : due < today ? (r < 0.3 ? "overdue" : "paid") : r < 0.7 ? "open" : "paid" if (status === "overdue" && due >= today) status = "open" const seats = 1 + Math.floor(rand() * 12) return { id: `INV-${2100 - i}`, customer, email, plan: tier.plan, status, owner: owners[Math.floor(rand() * owners.length)].name, issued, due, amount: Math.round(tier.base * seats * (0.9 + rand() * 0.2)) * 100, } })}
const statusMeta: Record<InvoiceStatus, { label: string; variant: StatusVariant }> = { paid: { label: "Paid", variant: "success" }, open: { label: "Open", variant: "info" }, overdue: { label: "Overdue", variant: "destructive" }, draft: { label: "Draft", variant: "neutral" }, void: { label: "Void", variant: "neutral" },}
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })const shortDate = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" })
const helper = createDataTableColumnHelper<Invoice>()
const columns = helper.columns([ dataTableSelectColumn<Invoice>(), helper.accessor("id", { header: ({ column }) => <DataTableColumnHeader column={column} title="Invoice" />, meta: { label: "Invoice" }, cell: ({ getValue }) => <span className="font-mono text-[13px] text-foreground">{getValue()}</span>, }), helper.accessor("customer", { header: ({ column }) => <DataTableColumnHeader column={column} title="Customer" />, meta: { label: "Customer" }, cell: ({ row }) => ( <div className="flex min-w-0 flex-col"> <span className="truncate font-medium text-foreground">{row.original.customer}</span> <span className="truncate text-[13px] text-muted-foreground">{row.original.email}</span> </div> ), }), helper.accessor("status", { header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, meta: { label: "Status" }, filterFn: "facet", cell: ({ getValue }) => { const s = statusMeta[getValue()] return ( <Status variant={s.variant} appearance="pill"> {s.label} </Status> ) }, }), helper.accessor("plan", { header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />, meta: { label: "Plan" }, filterFn: "facet", cell: ({ getValue }) => <span className="text-muted-foreground">{getValue()}</span>, }), helper.accessor("owner", { header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />, meta: { label: "Owner" }, filterFn: "facet", cell: ({ getValue }) => { const owner = owners.find((o) => o.name === getValue())! return ( <div className="flex items-center gap-2"> <Avatar size="sm"> <AvatarFallback>{owner.initials}</AvatarFallback> </Avatar> <span className="truncate">{owner.name}</span> </div> ) }, }), helper.accessor("due", { header: ({ column }) => <DataTableColumnHeader column={column} title="Due" />, meta: { label: "Due date", align: "end" }, sortFn: "datetime", enableGlobalFilter: false, cell: ({ getValue, row }) => ( <span className={row.original.status === "overdue" ? "text-destructive-subtle-foreground" : "text-muted-foreground"}> {shortDate.format(getValue())} </span> ), }), helper.accessor("amount", { header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />, meta: { label: "Amount", align: "end" }, enableGlobalFilter: false, cell: ({ getValue }) => <span className="font-medium text-foreground">{currency.format(getValue() / 100)}</span>, }), helper.display({ id: "actions", enableHiding: false, meta: { className: "w-12" }, cell: ({ row }) => ( <DataTableRowActions label={`Actions for ${row.original.id}`}> <DropdownMenuItem> <FileTextIcon /> View invoice </DropdownMenuItem> <DropdownMenuItem> <MailIcon /> Send reminder </DropdownMenuItem> <DropdownMenuItem> <CopyIcon /> Duplicate </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive"> <Trash2Icon /> Void invoice </DropdownMenuItem> </DataTableRowActions> ), }),])
const filters: DataTableFilterConfig[] = [ { column: "status", title: "Status", pinned: true, options: (Object.keys(statusMeta) as InvoiceStatus[]).map((s) => ({ value: s, label: statusMeta[s].label, icon: <StatusIndicator variant={statusMeta[s].variant} />, })), }, { column: "owner", title: "Owner", pinned: true, options: owners.map((o) => ({ value: o.name, label: o.name, icon: ( <Avatar size="sm"> <AvatarFallback>{o.initials}</AvatarFallback> </Avatar> ), })), }, { column: "plan", title: "Plan", options: plans.map((p) => ({ value: p.plan, label: p.plan })), },]
export default function DataTableInvoices() { const [data, setData] = React.useState(makeInvoices) const table = useDataTable({ data, columns, getRowId: (row) => row.id, initialState: { sorting: [{ id: "due", desc: true }], columnVisibility: { plan: false }, }, })
const markPaid = () => { const ids = new Set(Object.keys(table.state.rowSelection)) setData((rows) => rows.map((r) => (ids.has(r.id) ? { ...r, status: "paid" } : r))) table.resetRowSelection() }
return ( <DataTable table={table}> <DataTableToolbar> <DataTableSearch placeholder="Search invoices" /> <DataTableFilterBar filters={filters} /> <DataTableToolbarSpacer /> <DataTableViewOptions /> <Button variant="outline" size="sm"> <DownloadIcon data-icon="inline-start" /> Export </Button> </DataTableToolbar> <DataTableFilterChips filters={filters} /> <DataTableContent> <DataTableBulkActions> <Button variant="ghost" size="sm"> <MailIcon data-icon="inline-start" /> Send reminders </Button> <Button variant="ghost" size="sm"> <DownloadIcon data-icon="inline-start" /> Export </Button> <Button size="sm" onClick={markPaid}> <CircleCheckIcon data-icon="inline-start" /> Mark as paid </Button> </DataTableBulkActions> </DataTableContent> <DataTablePagination /> </DataTable> )}Sticky header, loading and empty
The header stays visible while the body scrolls. Loading shows skeleton rows in the real column layout, so nothing jumps when data arrives.
| Weekly active usage | |||||
|---|---|---|---|---|---|
| Northwind Logistics | Mid-market | Healthy | 347 | $15,544 | |
| Kestrel Health | Enterprise | At risk | 85 | $49,670 | |
| Brightline Studio | SMB | Healthy | 330 | $137,409 | |
| Ostara Foods | Mid-market | Healthy | 254 | $104,459 | |
| Halcyon Robotics | Enterprise | Churning | 490 | $38,801 | |
| Meridian Freight | SMB | At risk | 308 | $40,393 | |
| Cobalt Ridge Capital | Mid-market | At risk | 102 | $143,623 | |
| Lumen Analytics | Enterprise | Healthy | 440 | $13,189 | |
| Pinecrest Schools | SMB | Churning | 364 | $54,903 | |
| Tidewater Energy | Mid-market | At risk | 243 | $34,201 | |
| Fable & Finch | Enterprise | Healthy | 328 | $68,084 | |
| Granite Peak Outdoors | SMB | Healthy | 52 | $9,329 | |
| Verity Legal | Mid-market | At risk | 399 | $144,411 | |
| Sundial Hospitality | Enterprise | Healthy | 13 | $8,861 | |
| Arcwell Medical | SMB | Healthy | 284 | $109,047 | |
| Quill & Ink Press | Mid-market | At risk | 470 | $118,181 | |
| Riverbend Credit Union | Enterprise | Healthy | 201 | $34,252 | |
| Helio Solar Co. | SMB | Healthy | 95 | $37,687 | |
| Mosaic Learning | Mid-market | Churning | 349 | $110,712 | |
| Ironclad Security | Enterprise | Healthy | 392 | $100,352 |
"use client"
import * as React from "react"import { RefreshCwIcon } from "lucide-react"
import { Button } from "@/components/ui/button"import { DataTable, DataTableColumnHeader, DataTableContent, DataTableSearch, DataTableToolbar, DataTableToolbarSpacer, createDataTableColumnHelper, useDataTable,} from "@/components/ui/data-table"import { Sparkline } from "@/components/ui/sparkline"import { Status } from "@/components/ui/status"
type Account = { id: string name: string segment: string health: "healthy" | "at-risk" | "churning" seats: number usage: number[] arr: number}
const names = [ "Northwind Logistics", "Kestrel Health", "Brightline Studio", "Ostara Foods", "Halcyon Robotics", "Meridian Freight", "Cobalt Ridge Capital", "Lumen Analytics", "Pinecrest Schools", "Tidewater Energy", "Fable & Finch", "Granite Peak Outdoors", "Verity Legal", "Sundial Hospitality", "Arcwell Medical", "Quill & Ink Press", "Riverbend Credit Union", "Helio Solar Co.", "Mosaic Learning", "Ironclad Security",]const segments = ["Mid-market", "Enterprise", "SMB"]
function seeded(seed: number) { return () => { seed = (seed * 16807) % 2147483647 return (seed - 1) / 2147483646 }}
const accounts: Account[] = (() => { const rand = seeded(7) return names.map((name, i) => { const r = rand() const health = r < 0.6 ? "healthy" : r < 0.85 ? "at-risk" : "churning" const drift = health === "healthy" ? 1.5 : health === "at-risk" ? -0.6 : -2 let v = 40 + rand() * 30 const usage = Array.from({ length: 14 }, () => (v = Math.max(4, v + drift + (rand() - 0.5) * 8))) return { id: `acc_${(1040 + i * 7).toString(36)}`, name, segment: segments[i % 3], health, seats: 12 + Math.floor(rand() * 480), usage, arr: Math.round((8 + rand() * 140) * 1000), } })})()
const healthMeta = { healthy: { label: "Healthy", variant: "success" }, "at-risk": { label: "At risk", variant: "warning" }, churning: { label: "Churning", variant: "destructive" },} as const
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
const helper = createDataTableColumnHelper<Account>()const columns = helper.columns([ helper.accessor("name", { header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />, cell: ({ getValue }) => <span className="font-medium text-foreground">{getValue()}</span>, }), helper.accessor("segment", { header: ({ column }) => <DataTableColumnHeader column={column} title="Segment" />, cell: ({ getValue }) => <span className="text-muted-foreground">{getValue()}</span>, }), helper.accessor("health", { header: ({ column }) => <DataTableColumnHeader column={column} title="Health" />, cell: ({ getValue }) => { const h = healthMeta[getValue()] return <Status variant={h.variant}>{h.label}</Status> }, }), helper.accessor("usage", { header: "Weekly active usage", enableSorting: false, enableGlobalFilter: false, cell: ({ getValue, row }) => ( <Sparkline data={getValue()} className={ row.original.health === "healthy" ? "h-6 w-28 text-chart-1" : row.original.health === "at-risk" ? "h-6 w-28 text-warning-subtle-foreground" : "h-6 w-28 text-destructive-subtle-foreground" } showLastPoint /> ), }), helper.accessor("seats", { header: ({ column }) => <DataTableColumnHeader column={column} title="Seats" />, meta: { align: "end" }, enableGlobalFilter: false, }), helper.accessor("arr", { header: ({ column }) => <DataTableColumnHeader column={column} title="ARR" />, meta: { align: "end" }, enableGlobalFilter: false, cell: ({ getValue }) => <span className="font-medium text-foreground">{currency.format(getValue())}</span>, }),])
const empty: Account[] = []const modes = ["Loaded", "Loading", "Empty"] as const
export default function DataTableStates() { const [mode, setMode] = React.useState<(typeof modes)[number]>("Loaded") const table = useDataTable({ data: mode === "Empty" ? empty : accounts, columns, getRowId: (row) => row.id, initialState: { pagination: { pageIndex: 0, pageSize: 50 } }, })
return ( <DataTable table={table}> <DataTableToolbar> <DataTableSearch placeholder="Search accounts" /> <DataTableToolbarSpacer /> <div role="group" aria-label="Preview state" className="flex items-center gap-1"> {modes.map((m) => ( <Button key={m} size="sm" variant={mode === m ? "secondary" : "ghost"} aria-pressed={mode === m} onClick={() => setMode(m)} > {m === "Loading" && <RefreshCwIcon data-icon="inline-start" />} {m} </Button> ))} </div> </DataTableToolbar> <DataTableContent stickyHeader maxHeight={380} loading={mode === "Loading"} skeletonRows={7} /> </DataTable> )}Anatomy
const helper = createDataTableColumnHelper<Invoice>()const columns = helper.columns([ dataTableSelectColumn<Invoice>(), helper.accessor("status", { header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />, filterFn: "facet", }), helper.accessor("amount", { header: "Amount", meta: { align: "end" } }),])
const table = useDataTable({ data, columns, getRowId: (r) => r.id })
<DataTable table={table}> <DataTableToolbar> <DataTableSearch placeholder="Search invoices" /> <DataTableFilterBar filters={filters} /> <DataTableToolbarSpacer /> <DataTableViewOptions /> </DataTableToolbar> <DataTableFilterChips filters={filters} /> <DataTableContent> <DataTableBulkActions> <Button size="sm">Mark as paid</Button> </DataTableBulkActions> </DataTableContent> <DataTablePagination /></DataTable>Guidance
Use it when
- Lists of records people scan, compare and act on: invoices, customers, deployments, tickets.
- Up to a few thousand rows on the client. Beyond that, switch on manualSorting, manualFiltering and manualPagination and let the server do the work.
- Faceted filters for columns with a small set of known values (status, owner, plan). Search for free text.
Avoid
- Wrapping the table in another Card. DataTableContent already is the surface.
- More than one primary button in the bulk action bar.
- Faceted filters on columns with hundreds of unique values. Use search, or a dedicated combobox.
- Center-aligned numbers. Amounts, counts and comparable dates are right-aligned with meta.align set to end.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Create columns with createDataTableColumnHelper<TData>() and helper.columns([...]) at module scope, never inline in render.
- Always pass getRowId with a stable id from the data. Index ids break selection after sorting.
- Set filterFn: "facet" on every column used by DataTableFacetedFilter or DataTableFilterBar, and pass the same filters config to DataTableFilterChips.
- Set meta.align to "end" on money, counts and dates people compare. Set meta.label when the header is a component.
- Use dataTableSelectColumn<TData>() for selection instead of writing checkbox cells. Put DataTableBulkActions inside DataTableContent.
- Render status columns with Status appearance="pill", people with Avatar plus name, and ids in font-mono at 13px.
- Pass loading while fetching instead of swapping in a spinner. Do not wrap DataTableContent in a Card.
- Give each DataTableRowActions a label that names the record.
API
useDataTable
| Prop | Type | Default | Description |
|---|---|---|---|
| data | TData[] | Rows. Keep the array stable (state or memo); a new array every render recomputes every row model. | |
| columns | ColumnDef[] | Build with createDataTableColumnHelper<TData>().columns([...]) at module scope. | |
| getRowId | (row: TData) => string | Stable ids keep selection correct across sorting and pages. Always pass it. | |
| initialState | Partial<TableState> | { pagination: { pageIndex: 0, pageSize: 10 } } | Starting sort, filters, hidden columns and page size. |
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
| table | DataTableInstance | The instance from useDataTable. Every part below reads it from context. |
DataTableContent
| Prop | Type | Default | Description |
|---|---|---|---|
| loading | boolean | false | Shows skeleton rows in place of data and sets aria-busy. |
| stickyHeader | boolean | false | Pins the header row. Use with maxHeight. |
| maxHeight | number | string | Height of the scroll area. | |
| empty | ReactNode | Replaces the default empty state. The default adapts to whether filters are active. | |
| onRowClick | (row) => void | Makes rows clickable. Clicks on the checkbox and actions cells are ignored. |
DataTableFilterBar
| Prop | Type | Default | Description |
|---|---|---|---|
| filters | DataTableFilterConfig[] | { column, title, options, pinned }. Pinned filters always show; the rest sit under Add filter until used. |
DataTableFilterChips
| Prop | Type | Default | Description |
|---|---|---|---|
| filters | DataTableFilterConfig[] | Pass the same config so chips show titles and option labels. |
DataTableColumnHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| column / title | Column / string | A sort button with a direction icon. Sets aria-sort on the header cell. |
DataTablePagination
| Prop | Type | Default | Description |
|---|---|---|---|
| pageSizes | number[] | [10, 20, 50, 100] | Options for Rows per page. |
DataTableRowActions
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Row actions" | Accessible name of the trigger. Include the record, for example Actions for INV-2041. |
Column meta
| Prop | Type | Default | Description |
|---|---|---|---|
| align | "start" | "end" | end right-aligns header and cells and turns on tabular numbers. | |
| label | string | Name used in the View menu and filter chips when the header is a component. |