Data displayui
Data table
Sortable, searchable, paginated table with row selection (TanStack Table v9).
| Status | ||||
|---|---|---|---|---|
| acme-web-1 | Next.js | Ready | 120 | |
| orbit-api-2 | Astro | Ready | 8,039 | |
| nova-docs-3 | Hono | Building | 15,958 | |
| pulse-worker-4 | Remix | Error | 23,877 | |
| relay-web-5 | SvelteKit | Ready | 31,796 | |
| atlas-api-6 | Next.js | Ready | 39,715 | |
| acme-docs-7 | Astro | Ready | 47,634 | |
| orbit-worker-8 | Hono | Building | 5,553 | |
| nova-web-9 | Remix | Error | 13,472 | |
| pulse-api-10 | SvelteKit | Ready | 21,391 |
34 rows
Page 1 of 4
Installation
npx shadcn@latest add @particular/data-tablenpm dependencies
- @tanstack/react-table@^9.2.4
- cn@^0.4.0
- lucide-react@^1.51.0
Installs these registry items
Source
"use client"
import * as React from "react"
import {
columnFilteringFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_text,
tableFeatures,
useTable,
type Column,
type ColumnDef,
type RowData,
} from "@tanstack/react-table"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const dataTableFeatures = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString },
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature,
})
type DataTableFeatures = typeof dataTableFeatures
/** Column helper bound to the features DataTable registers. */
function createDataTableColumnHelper<TData extends RowData>() {
return createColumnHelper<DataTableFeatures, TData>()
}
function DataTableColumnHeader<TData extends RowData, TValue>({
column,
title,
className,
}: {
column: Column<DataTableFeatures, TData, TValue>
title: string
className?: string
}) {
if (!column.getCanSort()) return <span className={className}>{title}</span>
const sorted = column.getIsSorted()
const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ArrowUpDownIcon
return (
<Button
variant="ghost"
size="xs"
className={cn("-ml-2 text-muted-foreground data-[sorted=true]:text-foreground", className)}
data-sorted={Boolean(sorted)}
onClick={column.getToggleSortingHandler()}
>
{title}
<Icon data-icon="inline-end" className={cn(!sorted && "opacity-50")} />
</Button>
)
}
const pageSizes = [10, 20, 50]
function DataTable<TData extends RowData>({
columns,
data,
getRowId,
searchPlaceholder = "Search…",
searchable = true,
selectable = false,
initialPageSize = 10,
toolbar,
empty = "No results.",
className,
}: {
columns: Array<ColumnDef<DataTableFeatures, TData>>
data: TData[]
getRowId?: (row: TData, index: number) => string
searchPlaceholder?: string
searchable?: boolean
selectable?: boolean
initialPageSize?: number
/** Controls rendered next to the search field, e.g. filters or bulk actions on the selected rows. */
toolbar?: (context: { selectedRows: TData[]; clearSelection: () => void }) => React.ReactNode
empty?: React.ReactNode
className?: string
}) {
const allColumns = React.useMemo<Array<ColumnDef<DataTableFeatures, TData>>>(() => {
if (!selectable) return columns
const select: ColumnDef<DataTableFeatures, TData> = {
id: "select",
enableSorting: false,
enableGlobalFilter: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
/>
),
}
return [select, ...columns]
}, [columns, selectable])
const table = useTable({
features: dataTableFeatures,
columns: allColumns,
data,
getRowId,
globalFilterFn: "includesString",
initialState: { pagination: { pageIndex: 0, pageSize: initialPageSize } },
})
const selectedRows = table.getSelectedRowModel().rows
const { pagination, globalFilter } = table.state
const rows = table.getRowModel().rows
const filteredCount = table.getFilteredRowModel().rows.length
return (
<div data-slot="data-table" className={cn("flex flex-col gap-3", className)}>
{(searchable || toolbar) && (
<div className="flex flex-wrap items-center gap-2">
{searchable && (
<InputGroup className="w-full max-w-72">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
aria-label="Search table"
placeholder={searchPlaceholder}
value={typeof globalFilter === "string" ? globalFilter : ""}
onChange={(event) => table.setGlobalFilter(event.target.value)}
/>
</InputGroup>
)}
{toolbar?.({
selectedRows: selectedRows.map((row) => row.original),
clearSelection: () => table.resetRowSelection(true),
})}
</div>
)}
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader className="bg-muted/40">
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id}>
{group.headers.map((header) => (
<TableHead
key={header.id}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: undefined
}
className={cn(header.column.id === "select" && "w-10")}
>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length ? (
rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
{row.getAllCells().map((cell) => (
<TableCell key={cell.id}>
<table.FlexRender cell={cell} />
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={allColumns.length} className="h-24 text-center text-muted-foreground">
{empty}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted-foreground">
<span aria-live="polite">
{selectable && selectedRows.length > 0
? `${selectedRows.length} of ${filteredCount} selected`
: `${filteredCount} ${filteredCount === 1 ? "row" : "rows"}`}
</span>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
Rows per page
<NativeSelect
size="sm"
value={pagination.pageSize}
onChange={(event) => table.setPageSize(Number(event.target.value))}
className="w-16"
>
{pageSizes.map((size) => (
<NativeSelectOption key={size} value={size}>
{size}
</NativeSelectOption>
))}
</NativeSelect>
</label>
<span className="tabular-nums">
Page {pagination.pageIndex + 1} of {Math.max(1, table.getPageCount())}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="Previous page"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Next page"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
<ChevronRightIcon />
</Button>
</div>
</div>
</div>
</div>
)
}
export { DataTable, DataTableColumnHeader, createDataTableColumnHelper, dataTableFeatures, type DataTableFeatures }