Data Table
Sortable, filterable table with row selection and pagination, powered by TanStack Table, with composable cells for text, media, trends, progress, and row actions.
npx shadcn@latest add @velaui/data-tablepnpm dlx velaui add @velaui/data-tableyarn dlx shadcn@latest add @velaui/data-tablebunx --bun shadcn@latest add @velaui/data-tableveni add data-tableInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/data-tablepnpm dlx velaui add @velaui/data-tableyarn dlx shadcn@latest add @velaui/data-tablebunx --bun shadcn@latest add @velaui/data-tableExamples
- Default
- People
- Metrics
- Selection
- Progress
Usage
Props
DataTable
| Prop | Type | Default |
|---|---|---|
bordered | boolean | true |
columnsrequired | ColumnDef<TData, TValue>[] | — |
datarequired | TData[] | — |
filterColumn | string | — |
filterPlaceholder | string | "Filter…" |
pageSize | number | — |
Takes these and every prop of DataTableProps<TData, TValue>.
DataTableColumnHeader
| Prop | Type | Default |
|---|---|---|
columnrequired | Column<TData, TValue> | — |
titlerequired | string | — |
Takes these and every prop of DataTableColumnHeaderProps<TData, TValue>.
DataTablePagination
| Prop | Type | Default |
|---|---|---|
tablerequired | TanStackTable<TData> | — |
Takes these and every prop of DataTablePaginationProps<TData>.
Dependencies
- npm
@tanstack/react-table@8.21.3- Installs with it
- InputPaginationTable3 icons
Source
components/ui/data-table.tsx
"use client";
import * as React from "react";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type Column,
type ColumnDef,
type ColumnFiltersState,
type RowData,
type SortingState,
type Table as TanStackTable,
} from "@tanstack/react-table";
import { ChevronDown } from "@/components/icons/chevron-down";
import { ChevronUp } from "@/components/icons/chevron-up";
import { SortVertical01 } from "@/components/icons/sort-vertical-01";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
/** Column-level alignment, so a column def can right-align its head and cells at once. */
declare module "@tanstack/react-table" {
// The type parameters are unused here but cannot be renamed or dropped:
// TypeScript requires every declaration of a merged interface to carry
// identical type parameters, so `_TData` would break the augmentation.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
align?: "left" | "right";
}
}
/* -------------------------------------------------------------------------- */
/* Column header */
/* -------------------------------------------------------------------------- */
export type DataTableColumnHeaderProps<TData, TValue> =
React.HTMLAttributes<HTMLElement> & {
column: Column<TData, TValue>;
title: string;
};
/**
* Header label with the design's sort affordance. A column that never sorts
* renders the label alone — the icon only appears once `enableSorting` is on.
*/
function DataTableColumnHeader<TData, TValue>({
className,
column,
title,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
const align = column.columnDef.meta?.align ?? "left";
const sorted = column.getIsSorted();
if (!column.getCanSort()) {
return (
<div
data-slot="data-table-column-header"
className={cn(
"flex items-center gap-2",
align === "right" && "justify-end",
className,
)}
{...props}
>
{title}
</div>
);
}
const SortIcon =
sorted === "asc" ? ChevronUp : sorted === "desc" ? ChevronDown : SortVertical01;
return (
<button
type="button"
data-slot="data-table-column-header"
data-sort={sorted || "none"}
onClick={() => column.toggleSorting(sorted === "asc")}
className={cn(
"flex w-full items-center gap-2 outline-none focus-visible:text-text-strong",
align === "right" && "justify-end",
className,
)}
{...props}
>
{title}
{sorted ? (
<span className="sr-only">
{sorted === "asc" ? "(sorted ascending)" : "(sorted descending)"}
</span>
) : null}
<SortIcon data-slot="data-table-sort-icon" className="size-4 shrink-0" />
</button>
);
}
/* -------------------------------------------------------------------------- */
/* Pagination */
/* -------------------------------------------------------------------------- */
/**
* Page numbers around the current page, with `null` marking an ellipsis.
* Long ranges collapse to `1 … n-1 n n+1 … last`.
*/
function pageWindow(current: number, total: number): (number | null)[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const window = [current - 1, current, current + 1].filter(
(page) => page > 1 && page < total,
);
return [
1,
...(window[0]! > 2 ? [null] : []),
...window,
...(window[window.length - 1]! < total - 1 ? [null] : []),
total,
];
}
export type DataTablePaginationProps<TData> = React.ComponentProps<"div"> & {
table: TanStackTable<TData>;
};
function DataTablePagination<TData>({
className,
table,
...props
}: DataTablePaginationProps<TData>) {
const { pageIndex } = table.getState().pagination;
const pageCount = table.getPageCount();
return (
<div
data-slot="data-table-pagination"
className={cn("flex items-center justify-between gap-4 py-3", className)}
{...props}
>
<span role="status" className="text-sm leading-[1.4] text-text-muted">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</span>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={!table.getCanPreviousPage() || undefined}
onClick={(event) => {
event.preventDefault();
table.previousPage();
}}
/>
</PaginationItem>
{pageWindow(pageIndex + 1, pageCount).map((page, index) => (
<PaginationItem key={page ?? `ellipsis-${index}`}>
{page === null ? (
<PaginationEllipsis />
) : (
<PaginationLink
href="#"
isActive={page === pageIndex + 1}
onClick={(event) => {
event.preventDefault();
table.setPageIndex(page - 1);
}}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={!table.getCanNextPage() || undefined}
onClick={(event) => {
event.preventDefault();
table.nextPage();
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Data table */
/* -------------------------------------------------------------------------- */
export type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
/** Column id wired to the search field — omit it to drop the toolbar. */
filterColumn?: string;
filterPlaceholder?: string;
/** Rows per page. Omit it to render every row without pagination. */
pageSize?: number;
/** Frame the table in a rounded outline. */
bordered?: boolean;
};
function DataTable<TData, TValue>({
columns,
data,
filterColumn,
filterPlaceholder = "Filter…",
pageSize,
bordered = true,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [filterInput, setFilterInput] = React.useState("");
const [rowSelection, setRowSelection] = React.useState({});
// Keystrokes stay instant; the (potentially large) re-filter runs at a
// lower priority one render behind the input.
const deferredFilter = React.useDeferredValue(filterInput);
const columnFilters = React.useMemo<ColumnFiltersState>(
() =>
filterColumn && deferredFilter
? [{ id: filterColumn, value: deferredFilter }]
: [],
[filterColumn, deferredFilter],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
...(pageSize
? {
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize } },
}
: {}),
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
state: { sorting, columnFilters, rowSelection },
});
return (
<div data-slot="data-table" className="w-full">
{filterColumn ? (
<div data-slot="data-table-toolbar" className="flex items-center pb-3">
<Input
value={filterInput}
onChange={(event) => setFilterInput(event.target.value)}
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
className="max-w-64"
/>
</div>
) : null}
<Table bordered={bordered}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
align={header.column.columnDef.meta?.align}
aria-sort={
sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="[content-visibility:auto] [contain-intrinsic-size:auto_49px]"
data-state={row.getIsSelected() ? "selected" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
align={cell.column.columnDef.meta?.align}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-text-muted hover:bg-transparent"
>
<div role="status">No results.</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{pageSize ? <DataTablePagination table={table} /> : null}
</div>
);
}
export { DataTable, DataTableColumnHeader, DataTablePagination };