Data Table
A sortable, filterable, paginated table built on TanStack Table.
| Ada Lovelace | ada@analytical.dev | Paid | $316.00 | |
| Grace Hopper | grace@cobol.org | Pending | $242.00 | |
| Alan Turing | alan@bletchley.uk | Paid | $837.00 | |
| Katherine Johnson | katherine@nasa.gov | Failed | $125.00 | |
| Margaret Hamilton | margaret@apollo.space | Paid | $721.00 |
0 of 12 row(s) selected.
Page 1 of 3
'use client'
import {
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
type Payment = {
id: string
customer: string
email: string
status: 'Paid' | 'Pending' | 'Failed'
amount: number
}
const data: Payment[] = [
{ id: 'INV-1001', customer: 'Ada Lovelace', email: 'ada@analytical.dev', status: 'Paid', amount: 316 },
{ id: 'INV-1002', customer: 'Grace Hopper', email: 'grace@cobol.org', status: 'Pending', amount: 242 },
{ id: 'INV-1003', customer: 'Alan Turing', email: 'alan@bletchley.uk', status: 'Paid', amount: 837 },
{ id: 'INV-1004', customer: 'Katherine Johnson', email: 'katherine@nasa.gov', status: 'Failed', amount: 125 },
{ id: 'INV-1005', customer: 'Margaret Hamilton', email: 'margaret@apollo.space', status: 'Paid', amount: 721 },
{ id: 'INV-1006', customer: 'Linus Torvalds', email: 'linus@kernel.org', status: 'Pending', amount: 459 },
{ id: 'INV-1007', customer: 'Barbara Liskov', email: 'barbara@mit.edu', status: 'Paid', amount: 598 },
{ id: 'INV-1008', customer: 'Donald Knuth', email: 'don@stanford.edu', status: 'Failed', amount: 64 },
{ id: 'INV-1009', customer: 'Radia Perlman', email: 'radia@spanningtree.net', status: 'Paid', amount: 903 },
{ id: 'INV-1010', customer: 'Tim Berners-Lee', email: 'tim@w3.org', status: 'Pending', amount: 377 },
{ id: 'INV-1011', customer: 'Hedy Lamarr', email: 'hedy@spread.spectrum', status: 'Paid', amount: 281 },
{ id: 'INV-1012', customer: 'Dennis Ritchie', email: 'dennis@bell-labs.com', status: 'Paid', amount: 512 },
]
// Register only the table features this table uses; each row-model slot follows the feature it needs.
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
columnVisibilityFeature,
rowSelectionFeature,
rowPaginationFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
filterFns: { includesString: filterFn_includesString },
})
const columnHelper = createColumnHelper<typeof features, Payment>()
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const columns = columnHelper.columns([
columnHelper.display({
id: 'select',
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={value => table.toggleAllPageRowsSelected(value)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label={`Select ${row.original.customer}`}
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(value)}
/>
),
}),
columnHelper.accessor('customer', { header: 'Customer', cell: info => <span className="font-medium">{info.getValue()}</span> }),
columnHelper.accessor('email', { header: 'Email', filterFn: 'includesString' }),
columnHelper.accessor('status', { header: 'Status' }),
columnHelper.accessor('amount', {
header: 'Amount',
sortFn: 'basic',
meta: { align: 'right' },
cell: info => currency.format(info.getValue()),
}),
])
export default function DataTableDemo() {
const table = useTable({
features,
columns,
data,
initialState: { pagination: { pageIndex: 0, pageSize: 5 } },
})
const selected = table.getFilteredSelectedRowModel().rows.length
const filtered = table.getFilteredRowModel().rows.length
return (
<div className="w-full">
<div className="flex items-center gap-2 pb-4">
<Input
aria-label="Filter emails"
placeholder="Filter emails…"
value={(table.getColumn('email')?.getFilterValue() as string) ?? ''}
onChange={event => table.getColumn('email')?.setFilterValue(event.target.value)}
className="max-w-xs"
/>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" className="ml-auto">Columns</Button>} />
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter(column => column.getCanHide())
.map(column => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={value => column.toggleVisibility(value)}
className="capitalize"
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
const column = header.column
const sorted = column.getIsSorted()
const right = (column.columnDef.meta as { align?: string } | undefined)?.align === 'right'
return (
<TableHead
key={header.id}
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined}
className={right ? 'text-right' : undefined}
>
{header.isPlaceholder ? null : column.getCanSort() ? (
<Button
variant="ghost"
size="sm"
className={right ? '-mr-3 ml-auto' : '-ml-3'}
onClick={column.getToggleSortingHandler()}
>
<table.FlexRender header={header} />
<SortIcon direction={sorted} />
</Button>
) : (
<table.FlexRender header={header} />
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map(row => (
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined} className="data-[state=selected]:bg-muted/60">
{row.getVisibleCells().map(cell => {
const right = (cell.column.columnDef.meta as { align?: string } | undefined)?.align === 'right'
return (
<TableCell key={cell.id} className={right ? 'text-right' : undefined}>
<table.FlexRender cell={cell} />
</TableCell>
)
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center text-muted-foreground">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 pt-4">
<p className="text-sm text-muted-foreground" aria-live="polite">
{selected} of {filtered} row(s) selected.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Page {table.state.pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
</span>
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
)
}
function SortIcon({ direction }: { direction: false | 'asc' | 'desc' }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={direction ? 'opacity-100' : 'opacity-40'}>
{direction === 'asc' ? <path d="m18 15-6-6-6 6" /> : direction === 'desc' ? <path d="m6 9 6 6 6-6" /> : <path d="m7 15 5 5 5-5M7 9l5-5 5 5" />}
</svg>
)
}
Built on Base UI
Installation
pnpm dlx shadcn@latest add https://kagadmodyaa.vercel.app/r/data-table.jsonNeeds a React + Tailwind project set up with shadcn. Copies the component source straight into your project, so you can edit it.
Usage
// A data table isn't one component — it's our Table driven by TanStack Table (v9).
// Installing this item copies a complete, editable example into components/data-table.tsx
// along with the table, checkbox, dropdown-menu, button and input it uses.
import DataTable from '@/components/data-table'
export default function Page() {
return <DataTable />
}
// Then change three things in the file:
// 1. `Payment` and `data` — your row type and rows (or pass them in as props).
// 2. `columns` — one columnHelper.accessor per column; cell() renders, header is the title.
// 3. `features` — register only what you use. Each row-model slot (sorted, filtered, paginated)
// goes after its feature.Examples
Minimal: sorting only
| Button | 1.2.0 | 8,421 |
| Toast | 2.0.0 | 6,105 |
| Dialog | 1.0.3 | 5,290 |
| Tabs | 1.1.0 | 3,977 |
'use client'
import {
createColumnHelper,
createSortedRowModel,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from '@tanstack/react-table'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
type Release = { name: string; version: string; downloads: number }
const data: Release[] = [
{ name: 'Button', version: '1.2.0', downloads: 8421 },
{ name: 'Dialog', version: '1.0.3', downloads: 5290 },
{ name: 'Tabs', version: '1.1.0', downloads: 3977 },
{ name: 'Toast', version: '2.0.0', downloads: 6105 },
]
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
})
const columnHelper = createColumnHelper<typeof features, Release>()
const columns = columnHelper.columns([
columnHelper.accessor('name', { header: 'Component' }),
columnHelper.accessor('version', { header: 'Version', enableSorting: false }),
columnHelper.accessor('downloads', { header: 'Downloads', sortFn: 'basic', cell: info => info.getValue().toLocaleString('en-US') }),
])
/** The smallest useful data table: one feature (sorting) and our Table. Click a header to sort. */
export default function DataTableMinimal() {
const table = useTable({ features, columns, data, initialState: { sorting: [{ id: 'downloads', desc: true }] } })
return (
<div className="w-full rounded-lg border border-border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
{group.headers.map(header => (
<TableHead key={header.id} className="p-0">
<button
type="button"
disabled={!header.column.getCanSort()}
onClick={header.column.getToggleSortingHandler()}
className="flex h-10 w-full cursor-pointer items-center gap-1 px-2 text-left font-medium outline-none focus-visible:bg-accent disabled:cursor-default"
>
<table.FlexRender header={header} />
{{ asc: '↑', desc: '↓' }[header.column.getIsSorted() as string] ?? null}
</button>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getAllCells().map(cell => (
<TableCell key={cell.id}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
API Reference
| Name | Type | Default | Description |
|---|---|---|---|
| features | tableFeatures({ … }) | — | Sorting, column filtering, column visibility, row selection and pagination, each with its row model. Remove what you do not need. |
| columnHelper.accessor(key, def) | ColumnDef | — | A data column. header is its title, cell renders a value, sortFn / filterFn pick the algorithm, enableSorting / enableHiding opt out. |
| columnHelper.display({ id, header, cell }) | ColumnDef | — | A column with no data of its own, like the checkbox column. |
| useTable({ features, columns, data }) | Table | — | Keep columns and data stable (module scope or memoised). initialState sets page size, sorting and so on. |
| table.FlexRender | component | — | Renders a header or cell definition: <table.FlexRender header={header} /> or cell={cell}. |
| Server-side data | manualSorting / manualFiltering / manualPagination | — | Set the matching manual* option and feed the table already-sorted, filtered or paged data. |