Data Table

Enhanced data table with sorting, filtering, selection, and pagination controls.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/data-table.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import * as React from "react"; import { type ColumnDef, type ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type Row, type RowData, type RowSelectionState, type SortingState, useReactTable, } from "@tanstack/react-table"; import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { Checkbox } from "../checkbox"; import { Input } from "../input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../table"; export type DataTableFilterOption = { label: string; value: string; }; export type DataTableFilter = { columnId: string; label: string; options: DataTableFilterOption[]; }; export type DataTableProps<TData extends RowData> = React.HTMLAttributes<HTMLDivElement> & { caption?: string; columns: ColumnDef<TData>[]; data: TData[]; emptyMessage?: string; enableFiltering?: boolean; enablePagination?: boolean; enableSelection?: boolean; filterableColumns?: DataTableFilter[]; getRowId?: ( originalRow: TData, index: number, parent?: Row<TData>, ) => string; pageSize?: number; searchPlaceholder?: string; }; function SortIcon({ direction }: { direction: "asc" | "desc" | false }) { if (direction === "asc") { return <ArrowUp className="size-4" />; } if (direction === "desc") { return <ArrowDown className="size-4" />; } return <ArrowUpDown className="size-4" />; } const EMPTY_FILTERABLE_COLUMNS: DataTableFilter[] = []; function DataTableComponent<TData extends RowData>({ caption, className, columns, data, emptyMessage = "No results found.", enableFiltering = true, enablePagination = true, enableSelection = false, filterableColumns = EMPTY_FILTERABLE_COLUMNS, getRowId, pageSize = 10, searchPlaceholder = "Search rows...", ...props }: DataTableProps<TData>) { const [sorting, setSorting] = React.useState<SortingState>([]); const [globalFilter, setGlobalFilter] = React.useState(""); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( [], ); const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({}); const selectionColumn = React.useMemo<ColumnDef<TData>>( () => ({ cell: ({ row }) => ( <Checkbox aria-label={`Select row ${row.index + 1}`} checked={row.getIsSelected()} onCheckedChange={(checked) => { row.toggleSelected(Boolean(checked)); }} /> ), enableHiding: false, enableSorting: false, header: ({ table }) => ( <Checkbox aria-label="Select all rows" checked={ table.getIsAllPageRowsSelected() ? true : table.getIsSomePageRowsSelected() ? "indeterminate" : false } onCheckedChange={(checked) => { table.toggleAllPageRowsSelected(Boolean(checked)); }} /> ), id: "select", size: 40, }), [], ); const resolvedColumns = React.useMemo( () => (enableSelection ? [selectionColumn, ...columns] : columns), [columns, enableSelection, selectionColumn], ); const table = useReactTable({ columns: resolvedColumns, data, enableRowSelection: enableSelection, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getRowId, getSortedRowModel: getSortedRowModel(), initialState: { pagination: { pageIndex: 0, pageSize, }, }, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, state: { columnFilters, globalFilter, rowSelection, sorting, }, }); return ( <div className={cn("space-y-4", className)} {...props}> {enableFiltering ? ( <div className="flex flex-col gap-3 rounded-xl border bg-card p-4 sm:flex-row sm:items-center sm:justify-between"> <Input className="w-full sm:max-w-sm" onChange={(event) => { setGlobalFilter(event.target.value); }} placeholder={searchPlaceholder} value={globalFilter} /> {filterableColumns.length > 0 ? ( <div className="flex flex-wrap gap-2"> {filterableColumns.map((filter) => { const column = table.getColumn(filter.columnId); const value = column?.getFilterValue(); const selectValue = typeof value === "string" && value ? value : "all"; return column ? ( <Select key={filter.columnId} onValueChange={(nextValue) => { column.setFilterValue( nextValue === "all" ? undefined : nextValue, ); }} value={selectValue} > <SelectTrigger className="w-[180px]"> <SelectValue placeholder={filter.label} /> </SelectTrigger> <SelectContent> <SelectItem value="all">All {filter.label}</SelectItem> {filter.options.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectContent> </Select> ) : null; })} </div> ) : null} </div> ) : null} <div className="rounded-xl border bg-card"> <Table> {caption ? <caption className="sr-only">{caption}</caption> : null} <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => ( <TableHead key={header.id}> {header.isPlaceholder ? null : header.column.getCanSort() ? ( <Button className="-ml-3 h-8 px-3 text-xs font-medium" onClick={header.column.getToggleSortingHandler()} type="button" variant="ghost" > {flexRender( header.column.columnDef.header, header.getContext(), )} <SortIcon direction={header.column.getIsSorted()} /> </Button> ) : ( flexRender( header.column.columnDef.header, header.getContext(), ) )} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map((row) => ( <TableRow data-state={row.getIsSelected() ? "selected" : undefined} key={row.id} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender( cell.column.columnDef.cell, cell.getContext(), )} </TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell className="h-24 text-center text-muted-foreground" colSpan={resolvedColumns.length} > {emptyMessage} </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex flex-col gap-3 rounded-xl border bg-card p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between"> <div> {enableSelection ? `${table.getSelectedRowModel().rows.length} selected` : `${table.getFilteredRowModel().rows.length} rows`} </div> {enablePagination ? ( <div className="flex items-center gap-2 self-end sm:self-auto"> <Button disabled={!table.getCanPreviousPage()} onClick={() => { table.previousPage(); }} type="button" variant="outline" > Previous </Button> <span className="min-w-24 text-center text-xs uppercase tracking-wide text-muted-foreground"> Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()} </span> <Button disabled={!table.getCanNextPage()} onClick={() => { table.nextPage(); }} type="button" variant="outline" > Next </Button> </div> ) : null} </div> </div> ); } export { DataTableComponent as DataTable };

Dependencies

  • @vllnt/ui@^0.2.1
  • @tanstack/react-table