# Data Table

TanStack Table shell with toolbar, pagination, sortable headers, and column visibility.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/data-table.json
```

[Registry JSON](https://ui.uptoolkit.com/r/data-table.json)

## Preview

```tsx
import { CrudDataTable } from "@/components/crud-data-table";

export function Preview() {
  return <CrudDataTable heading="Books" />;
}
```


## Source

### ui/data-table.tsx

```tsx
"use client";

import { flexRender, type Table as TanstackTable } from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";

type DataTableProps<TData> = {
  table: TanstackTable<TData>;
  className?: string;
  emptyMessage?: string;
};

function DataTable<TData>({
  table,
  className,
  emptyMessage = "No results.",
}: DataTableProps<TData>) {
  return (
    <div className={cn("rounded-lg border", className)}>
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead key={header.id} colSpan={header.colSpan}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(header.column.columnDef.header, header.getContext())}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.length > 0 ? (
            table.getRowModel().rows.map((row) => (
              <TableRow key={row.id} data-state={row.getIsSelected() ? "selected" : undefined}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
                {emptyMessage}
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

export { DataTable, type DataTableProps };
```


### ui/data-table-toolbar.tsx

```tsx
"use client";

import { IconAdjustmentsHorizontal, IconColumns, IconSearch, IconX } from "@tabler/icons-react";
import type { Column, Table as TanstackTable } from "@tanstack/react-table";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";

import type { DataTableColumnMeta } from "@/lib/data-table";
import { getColumnLabel } from "@/components/ui/data-table-column-header";

type DataTableToolbarProps<TData> = {
  table: TanstackTable<TData>;
  searchPlaceholder?: string;
  className?: string;
};

function DataTableToolbar<TData>({
  table,
  searchPlaceholder = "Search...",
  className,
}: DataTableToolbarProps<TData>) {
  const isFiltered =
    table.getState().columnFilters.length > 0 || Boolean(table.getState().globalFilter);
  const filterableColumns = table
    .getAllColumns()
    .filter((column) => Boolean((column.columnDef.meta as DataTableColumnMeta | undefined)?.filter));

  return (
    <div className={cn("flex flex-col gap-3", className)}>
      <div className="flex flex-wrap items-center gap-2">
        <div className="relative min-w-[12rem] flex-1">
          <IconSearch
            aria-hidden="true"
            className="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
          />
          <Input
            value={table.getState().globalFilter ?? ""}
            onChange={(event) => table.setGlobalFilter(event.target.value)}
            placeholder={searchPlaceholder}
            aria-label="Search table"
            className="pl-8"
          />
        </div>

        <Popover>
          <PopoverTrigger
            render={
              <Button variant="outline" size="sm">
                <IconAdjustmentsHorizontal aria-hidden="true" />
                Filters
                {table.getState().columnFilters.length > 0 ? (
                  <Badge variant="secondary">{table.getState().columnFilters.length}</Badge>
                ) : null}
              </Button>
            }
          />
          <PopoverContent align="start" className="w-80 space-y-4">
            {filterableColumns.length === 0 ? (
              <p className="m-0 text-sm text-muted-foreground">No column filters configured.</p>
            ) : (
              filterableColumns.map((column) => (
                <ColumnFilterControl key={column.id} column={column} />
              ))
            )}
          </PopoverContent>
        </Popover>

        <DropdownMenu>
          <DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
            <IconColumns aria-hidden="true" />
            Columns
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="w-48">
            {table
              .getAllColumns()
              .filter((column) => column.getCanHide())
              .map((column) => (
                <DropdownMenuCheckboxItem
                  key={column.id}
                  checked={column.getIsVisible()}
                  onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
                >
                  {getColumnLabel(column)}
                </DropdownMenuCheckboxItem>
              ))}
          </DropdownMenuContent>
        </DropdownMenu>

        {isFiltered ? (
          <Button variant="ghost" size="sm" onClick={() => table.resetColumnFilters()}>
            <IconX aria-hidden="true" />
            Reset
          </Button>
        ) : null}
      </div>
    </div>
  );
}

function ColumnFilterControl<TData>({ column }: { column: Column<TData, unknown> }) {
  const meta = column.columnDef.meta as DataTableColumnMeta | undefined;
  const filter = meta?.filter;

  if (!filter) {
    return null;
  }

  const value = column.getFilterValue();

  return (
    <div className="flex flex-col gap-1.5">
      <Label className="text-xs font-medium">{meta?.label ?? column.id}</Label>

      {filter.type === "text" ? (
        <Input
          value={typeof value === "string" ? value : ""}
          placeholder={filter.placeholder ?? "Filter..."}
          onChange={(event) => column.setFilterValue(event.target.value)}
        />
      ) : null}

      {filter.type === "boolean" ? (
        <Select
          value={value === undefined ? "all" : value === true ? "true" : "false"}
          onValueChange={(next: string | null) => {
            if (!next || next === "all") {
              column.setFilterValue(undefined);
              return;
            }

            column.setFilterValue(next === "true");
          }}
        >
          <SelectTrigger size="sm" className="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="all">All</SelectItem>
            <SelectItem value="true">Yes</SelectItem>
            <SelectItem value="false">No</SelectItem>
          </SelectContent>
        </Select>
      ) : null}

      {filter.type === "select" ? (
        <Select
          value={typeof value === "string" ? value : ""}
          onValueChange={(next: string | null) => column.setFilterValue(next || undefined)}
        >
          <SelectTrigger size="sm" className="w-full">
            <SelectValue placeholder={filter.placeholder ?? "Select..."} />
          </SelectTrigger>
          <SelectContent>
            {(filter.options ?? []).map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      ) : null}

      {filter.type === "multiselect" ? (
        <div className="flex flex-col gap-2">
          {(filter.options ?? []).map((option) => {
            const selected = Array.isArray(value) ? value.includes(option.value) : false;

            return (
              <label key={option.value} className="flex items-center gap-2 text-sm">
                <Checkbox
                  checked={selected}
                  onCheckedChange={(checked) => {
                    const current = Array.isArray(value) ? value : [];
                    column.setFilterValue(
                      checked
                        ? [...current, option.value]
                        : current.filter((entry) => entry !== option.value),
                    );
                  }}
                />
                {option.label}
              </label>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

export { DataTableToolbar, type DataTableToolbarProps };
```


### ui/data-table-pagination.tsx

```tsx
"use client";

import { IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from "@tabler/icons-react";
import type { Table as TanstackTable } from "@tanstack/react-table";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";

type DataTablePaginationProps<TData> = {
  table: TanstackTable<TData>;
  pageSizeOptions?: readonly number[];
  className?: string;
};

function DataTablePagination<TData>({
  table,
  pageSizeOptions = [10, 20, 30, 50],
  className,
}: DataTablePaginationProps<TData>) {
  const pageIndex = table.getState().pagination.pageIndex;
  const pageSize = table.getState().pagination.pageSize;
  const pageCount = table.getPageCount();
  const selected = table.getFilteredSelectedRowModel().rows.length;
  const total = table.getFilteredRowModel().rows.length;

  return (
    <div className={cn("flex flex-col gap-3 px-2 py-3 sm:flex-row sm:items-center sm:justify-between", className)}>
      <p className="m-0 text-sm text-muted-foreground">
        {selected > 0 ? (
          <>
            {selected} of {total} row(s) selected.
          </>
        ) : (
          <>Page {pageIndex + 1} of {Math.max(pageCount, 1)}</>
        )}
      </p>

      <div className="flex flex-wrap items-center gap-2">
        <div className="flex items-center gap-2">
          <span className="text-sm text-muted-foreground">Rows</span>
          <Select
            value={String(pageSize)}
            onValueChange={(value: string | null) => {
              if (value) {
                table.setPageSize(Number(value));
              }
            }}
          >
            <SelectTrigger size="sm" className="w-18">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {pageSizeOptions.map((option) => (
                <SelectItem key={option} value={String(option)}>
                  {option}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>

        <div className="flex items-center gap-1">
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
          >
            <IconChevronsLeft aria-hidden="true" />
            <span className="sr-only">First page</span>
          </Button>
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <IconChevronLeft aria-hidden="true" />
            <span className="sr-only">Previous page</span>
          </Button>
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <IconChevronRight aria-hidden="true" />
            <span className="sr-only">Next page</span>
          </Button>
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.setPageIndex(pageCount - 1)}
            disabled={!table.getCanNextPage()}
          >
            <IconChevronsRight aria-hidden="true" />
            <span className="sr-only">Last page</span>
          </Button>
        </div>
      </div>
    </div>
  );
}

export { DataTablePagination, type DataTablePaginationProps };
```


### ui/data-table-column-header.tsx

```tsx
"use client";

import {
  IconArrowDown,
  IconArrowsSort,
  IconArrowUp,
  IconEyeOff,
} from "@tabler/icons-react";
import type { Column } from "@tanstack/react-table";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

import type { DataTableColumnMeta } from "@/lib/data-table";

type DataTableColumnHeaderProps<TData, TValue> = React.ComponentProps<"div"> & {
  column: Column<TData, TValue>;
  title: string;
};

function DataTableColumnHeader<TData, TValue>({
  column,
  title,
  className,
}: DataTableColumnHeaderProps<TData, TValue>) {
  if (!column.getCanSort()) {
    return <div className={cn(className)}>{title}</div>;
  }

  const sorted = column.getIsSorted();

  return (
    <div className={cn("flex items-center gap-2", className)}>
      <DropdownMenu>
        <DropdownMenuTrigger
          render={
            <Button
              variant="ghost"
              size="sm"
              className="-ml-2 h-8 data-[state=open]:bg-accent"
            />
          }
        >
          <span>{title}</span>
          {sorted === "desc" ? (
            <IconArrowDown aria-hidden="true" className="size-4" />
          ) : sorted === "asc" ? (
            <IconArrowUp aria-hidden="true" className="size-4" />
          ) : (
            <IconArrowsSort aria-hidden="true" className="size-4" />
          )}
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
            <IconArrowUp aria-hidden="true" />
            Asc
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
            <IconArrowDown aria-hidden="true" />
            Desc
          </DropdownMenuItem>
          {column.getCanHide() ? (
            <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
              <IconEyeOff aria-hidden="true" />
              Hide
            </DropdownMenuItem>
          ) : null}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

function getColumnLabel<TData, TValue>(column: Column<TData, TValue>): string {
  const meta = column.columnDef.meta as DataTableColumnMeta | undefined;

  return meta?.label ?? column.id;
}

export { DataTableColumnHeader, getColumnLabel, type DataTableColumnHeaderProps };
```



## Usage

Composable TanStack Table primitives for CRUD admin UIs.

```tsx
import { DataTable } from "@/components/ui/data-table";
import { DataTableToolbar } from "@/components/ui/data-table-toolbar";
import { DataTablePagination } from "@/components/ui/data-table-pagination";
import { useCrudDataTable } from "@/hooks/use-crud-data-table";

const { table } = useCrudDataTable({ columns, data });

<>
  <DataTableToolbar table={table} />
  <DataTable table={table} />
  <DataTablePagination table={table} />
</>;
```

`DataTableToolbar` includes global search, per-column filters, and a column
visibility menu. `DataTableColumnHeader` adds sort and hide controls to each
sortable column.

Install `@tanstack/react-table` in your app:

```bash
npm install @tanstack/react-table
```

