Data Table Pro
A production-ready data table with sorting, filtering, column visibility, row selection, and pagination built on TanStack Table.
Installation
npx cano-ui add data-table-pro
Installs table, button, checkbox, dropdown-menu, input, select, skeleton, empty-state, @tanstack/react-table, lucide-react automatically if missing.
Source
"use client"
import * as React from "react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type Column,
type ColumnDef,
type ColumnFiltersState,
type RowSelectionState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import {
ArrowDown,
ArrowUp,
ChevronsUpDown,
SearchX,
Settings2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { EmptyState } from "@/components/ui/empty-state"
interface DataTableColumnHeaderProps<TData, TValue> {
column: Column<TData, TValue>
title: string
className?: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <span className={className}>{title}</span>
}
const sorted = column.getIsSorted()
return (
<button
type="button"
onClick={() => column.toggleSorting(sorted === "asc")}
className={cn(
"-ml-2 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-sm font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
sorted ? "text-foreground" : "text-muted-foreground",
className
)}
>
{title}
{sorted === "asc" ? (
<ArrowUp className="size-3.5" aria-hidden="true" />
) : sorted === "desc" ? (
<ArrowDown className="size-3.5" aria-hidden="true" />
) : (
<ChevronsUpDown
className="size-3.5 text-muted-foreground/70"
aria-hidden="true"
/>
)}
</button>
)
}
export interface DataTableProProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
/** Column id used by the toolbar search input. */
filterColumn?: string
filterPlaceholder?: string
/** Slot for extra filters, rendered next to the search input. */
toolbar?: React.ReactNode
/** Prepends a checkbox column and enables the bulk-actions bar. */
enableRowSelection?: boolean
/** Slot rendered inside the bulk-actions bar when rows are selected. */
bulkActions?: React.ReactNode
/** Reduced row height. */
compact?: boolean
/** Renders skeleton rows instead of data. */
loading?: boolean
loadingRows?: number
/** Renders the page-size Select in the footer. */
showPageSize?: boolean
pageSizeOptions?: number[]
initialPageSize?: number
emptyTitle?: string
emptyDescription?: string
className?: string
}
export function DataTablePro<TData, TValue>({
columns,
data,
filterColumn,
filterPlaceholder = "Filter…",
toolbar,
enableRowSelection = false,
bulkActions,
compact = false,
loading = false,
loadingRows = 5,
showPageSize = false,
pageSizeOptions = [10, 20, 30, 50],
initialPageSize = 10,
emptyTitle = "No results",
emptyDescription = "No rows match your filters. Try adjusting your search.",
className,
}: DataTableProProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const allColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {
if (!enableRowSelection) return columns
const selectColumn: ColumnDef<TData, TValue> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(value === true)
}
aria-label="Select all rows on this page"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(value === true)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
}
return [selectColumn, ...columns]
}, [columns, enableRowSelection])
const table = useReactTable({
data,
columns: allColumns,
state: { sorting, columnFilters, columnVisibility, rowSelection },
enableRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: initialPageSize } },
})
const visibleColumns = table
.getAllLeafColumns()
.filter((column) => column.getIsVisible())
const selectedCount = table.getFilteredSelectedRowModel().rows.length
const totalCount = table.getFilteredRowModel().rows.length
const cellPadding = compact ? "py-1.5" : "py-3"
return (
<div className={cn("flex w-full flex-col gap-3", className)}>
<div className="flex items-center gap-2">
{filterColumn ? (
<Input
value={
(table.getColumn(filterColumn)?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn(filterColumn)?.setFilterValue(event.target.value)
}
placeholder={filterPlaceholder}
className="h-8 w-full max-w-64"
aria-label={filterPlaceholder}
/>
) : null}
{toolbar}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto"
aria-label="Toggle column visibility"
>
<Settings2 aria-hidden="true" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllLeafColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{enableRowSelection && selectedCount > 0 ? (
<div className="flex h-10 items-center justify-between gap-3 rounded-md border bg-muted/50 px-3">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{selectedCount}
</span>{" "}
selected
</p>
<div className="flex items-center gap-2">{bulkActions}</div>
</div>
) : null}
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted()
return (
<TableHead
key={header.id}
aria-sort={
header.column.getCanSort()
? sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: "none"
: undefined
}
className={cn(header.column.id === "select" && "w-10")}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: loadingRows }).map((_, rowIndex) => (
<TableRow key={`skeleton-${rowIndex}`}>
{visibleColumns.map((column) => (
<TableCell key={column.id} className={cellPadding}>
{column.id === "select" ? (
<Skeleton className="size-4 rounded-sm" />
) : (
<Skeleton className="h-4 w-3/4" />
)}
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={cellPadding}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={visibleColumns.length} className="py-12">
<EmptyState
icon={SearchX}
title={emptyTitle}
description={emptyDescription}
/>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
{enableRowSelection
? `${selectedCount} of ${totalCount} row(s) selected`
: `${totalCount} row(s)`}
</p>
<div className="flex items-center gap-6">
{showPageSize ? (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Rows per page
</span>
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<SelectTrigger
className="h-8 w-[4.5rem]"
aria-label="Rows per page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{Math.max(table.getPageCount(), 1)}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Go to previous page"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Go to next page"
>
Next
</Button>
</div>
</div>
</div>
</div>
)
}
This exact file lands in your project at components/ui/data-table-pro.tsx. It is yours to edit.