Data Table

Enhanced data table with sorting, filtering, selection, and pagination controls. Install Data Table from the VLLNT UI registry with the shadcn CLI.

Report a bug

Enhanced data table with sorting, filtering, selection, and pagination controls. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

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

Installation

Add Data Table to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

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

Source

"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/checkbox"; import { Input } from "../input/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../select/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../table/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 };

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

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