Vela UI Docsvenizia.ai

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-table
npx shadcn@latest add @velaui/data-table

Examples

  • Default
  • People
  • Metrics
  • Selection
  • Progress

View code

Data Table · Default

Installation

pnpm dlx velaui add @velaui/data-table
pnpm dlx velaui add @velaui/data-table

Code

data-table-default.tsx
import type { ColumnDef } from '@tanstack/react-table';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellText } from '@/components/ui/table';

type Member = {
  name: string
  role: string
  status: string
}

const data: Member[] = [
  { name: 'Ava Turner', role: 'Designer', status: 'Active' },
  { name: 'Noah Reyes', role: 'Engineer', status: 'Invited' },
  { name: 'Iris Park', role: 'Product', status: 'Active' },
]

const columns: ColumnDef<Member>[] = [
  {
    accessorKey: 'name',
    header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
    cell: ({ row }) => <TableCellText>{row.original.name}</TableCellText>,
  },
  {
    accessorKey: 'role',
    header: ({ column }) => <DataTableColumnHeader column={column} title="Role" />,
    cell: ({ row }) => <TableCellText>{row.original.role}</TableCellText>,
  },
  {
    accessorKey: 'status',
    enableSorting: false,
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.status}</TableCellText>,
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} />
}

View code

Data Table · People

Installation

pnpm dlx velaui add @velaui/data-table
pnpm dlx velaui add @velaui/data-table

Code

data-table-people.tsx
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import {
  TableCellCaption,
  TableCellMedia,
  TableCellText,
} from '@/components/ui/table';

type Person = {
  name: string
  email: string
  avatar: string
  initials: string
  status: 'Active' | 'Invited'
}

const data: Person[] = [
  {
    name: 'Mia Chen',
    email: 'mia@venizia.ai',
    avatar: 'https://i.pravatar.cc/64?img=5',
    initials: 'MC',
    status: 'Active',
  },
  {
    name: 'Leo Fischer',
    email: 'leo@venizia.ai',
    avatar: 'https://i.pravatar.cc/64?img=12',
    initials: 'LF',
    status: 'Invited',
  },
]

const columns: ColumnDef<Person>[] = [
  {
    accessorKey: 'name',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Member" />
    ),
    cell: ({ row }) => (
      <TableCellMedia>
        <Avatar size="sm">
          <AvatarImage src={row.original.avatar} alt={row.original.name} />
          <AvatarFallback>{row.original.initials}</AvatarFallback>
        </Avatar>
        <div>
          <TableCellText bold>{row.original.name}</TableCellText>
          <TableCellCaption>{row.original.email}</TableCellCaption>
        </div>
      </TableCellMedia>
    ),
  },
  {
    accessorKey: 'status',
    enableSorting: false,
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ row }) => (
      <Badge
        variant="subtle"
        color={row.original.status === 'Active' ? 'success' : 'neutral'}
      >
        {row.original.status}
      </Badge>
    ),
  },
]

export default function Example() {
  return (
    <DataTable
      columns={columns}
      data={data}
      filterColumn="name"
      filterPlaceholder="Filter members…"
    />
  )
}

View code

Data Table · Metrics

Installation

pnpm dlx velaui add @velaui/data-table
pnpm dlx velaui add @velaui/data-table

Code

data-table-metrics.tsx
import type { ColumnDef } from '@tanstack/react-table';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellText, TableCellTrend } from '@/components/ui/table';

type Metric = {
  metric: string
  value: number
  change: number
}

const data: Metric[] = [
  { metric: 'Revenue', value: 48204, change: 12.4 },
  { metric: 'Active seats', value: 1290, change: 3.1 },
  { metric: 'Churn', value: 1.8, change: -0.3 },
]

const columns: ColumnDef<Metric>[] = [
  {
    accessorKey: 'metric',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Metric" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.metric}</TableCellText>,
  },
  {
    accessorKey: 'value',
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Value" />
    ),
    cell: ({ row }) => (
      <TableCellText bold>{row.original.value.toLocaleString()}</TableCellText>
    ),
  },
  {
    accessorKey: 'change',
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Change" />
    ),
    cell: ({ row }) => (
      <TableCellTrend trend={row.original.change < 0 ? 'down' : 'up'}>
        {row.original.change > 0 ? '+' : ''}
        {row.original.change}%
      </TableCellTrend>
    ),
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} />
}

View code

Data Table · Selection

Installation

pnpm dlx velaui add @velaui/data-table
pnpm dlx velaui add @velaui/data-table

Code

data-table-selection.tsx
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil01 } from "@/components/icons/pencil-01";
import { Trash01 } from "@/components/icons/trash-01";
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellActions, TableCellText } from '@/components/ui/table';

type Project = {
  id: string
  name: string
}

const data: Project[] = [
  { id: '1', name: 'Onboarding redesign' },
  { id: '2', name: 'Billing migration' },
  { id: '3', name: 'Design tokens v2' },
]

const columns: ColumnDef<Project>[] = [
  {
    id: 'select',
    enableSorting: false,
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all"
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() && 'indeterminate')
        }
        onCheckedChange={(value) =>
          table.toggleAllPageRowsSelected(Boolean(value))
        }
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label="Select row"
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
      />
    ),
  },
  {
    accessorKey: 'name',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Project" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.name}</TableCellText>,
  },
  {
    id: 'actions',
    enableSorting: false,
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Actions" />
    ),
    cell: () => (
      <TableCellActions className="justify-end">
        <Button variant="ghost" size="icon-sm" aria-label="Edit">
          <Pencil01 />
        </Button>
        <Button variant="ghost" size="icon-sm" aria-label="Delete">
          <Trash01 />
        </Button>
      </TableCellActions>
    ),
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} pageSize={2} />
}
import type { ColumnDef } from '@tanstack/react-table';
import { Pencil01 } from "@/components/icons/pencil-01";
import { Trash01 } from "@/components/icons/trash-01";
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellActions, TableCellText } from '@/components/ui/table';

type Project = {
  id: string
  name: string
}

const data: Project[] = [
  { id: '1', name: 'Onboarding redesign' },
  { id: '2', name: 'Billing migration' },
  { id: '3', name: 'Design tokens v2' },
]

const columns: ColumnDef<Project>[] = [
  {
    id: 'select',
    enableSorting: false,
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all"
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onCheckedChange={(value) =>
          table.toggleAllPageRowsSelected(Boolean(value))
        }
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label="Select row"
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
      />
    ),
  },
  {
    accessorKey: 'name',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Project" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.name}</TableCellText>,
  },
  {
    id: 'actions',
    enableSorting: false,
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Actions" />
    ),
    cell: () => (
      <TableCellActions className="justify-end">
        <Button variant="ghost" size="icon-sm" aria-label="Edit">
          <Pencil01 />
        </Button>
        <Button variant="ghost" size="icon-sm" aria-label="Delete">
          <Trash01 />
        </Button>
      </TableCellActions>
    ),
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} pageSize={2} />
}

View code

Data Table · Progress

Installation

pnpm dlx velaui add @velaui/data-table
pnpm dlx velaui add @velaui/data-table

Code

data-table-progress.tsx
import type { ColumnDef } from '@tanstack/react-table';
import { Progress } from '@/components/ui/progress';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellText } from '@/components/ui/table';

type Task = {
  task: string
  progress: number
}

const data: Task[] = [
  { task: 'Migrate storage', progress: 64 },
  { task: 'Ship release notes', progress: 100 },
  { task: 'Audit permissions', progress: 18 },
]

const columns: ColumnDef<Task>[] = [
  {
    accessorKey: 'task',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Task" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.task}</TableCellText>,
  },
  {
    accessorKey: 'progress',
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Progress" />
    ),
    cell: ({ row }) => (
      <Progress
        value={row.original.progress}
        variant={row.original.progress === 100 ? 'neutral' : 'accent'}
        className="max-w-40"
      />
    ),
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} />
}

Usage

import type { ColumnDef } from '@tanstack/react-table';
import {
  DataTable,
  DataTableColumnHeader,
} from '@/components/ui/data-table';
import { TableCellText } from '@/components/ui/table';

type Member = {
  name: string
  role: string
  status: string
}

const data: Member[] = [
  { name: 'Ava Turner', role: 'Designer', status: 'Active' },
  { name: 'Noah Reyes', role: 'Engineer', status: 'Invited' },
  { name: 'Iris Park', role: 'Product', status: 'Active' },
]

const columns: ColumnDef<Member>[] = [
  {
    accessorKey: 'name',
    header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
    cell: ({ row }) => <TableCellText>{row.original.name}</TableCellText>,
  },
  {
    accessorKey: 'role',
    header: ({ column }) => <DataTableColumnHeader column={column} title="Role" />,
    cell: ({ row }) => <TableCellText>{row.original.role}</TableCellText>,
  },
  {
    accessorKey: 'status',
    enableSorting: false,
    meta: { align: 'right' },
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ row }) => <TableCellText>{row.original.status}</TableCellText>,
  },
]

export default function Example() {
  return <DataTable columns={columns} data={data} />
}

Props

DataTable

PropTypeDefault
borderedbooleantrue
columnsrequiredColumnDef<TData, TValue>[]—
datarequiredTData[]—
filterColumnstring—
filterPlaceholderstring"Filter…"
pageSizenumber—

Takes these and every prop of DataTableProps<TData, TValue>.

DataTableColumnHeader

PropTypeDefault
columnrequiredColumn<TData, TValue>—
titlerequiredstring—

Takes these and every prop of DataTableColumnHeaderProps<TData, TValue>.

DataTablePagination

PropTypeDefault
tablerequiredTanStackTable<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 };