Free shadcn/ui data table for React — a reusable built on TanStack Table over the Table primitive. Sorting, row selection, faceted filters, column visibility, row actions, and pagination. The examples official shadcn never shipped.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

@tanstack/react-table
utils
table
button
@designrevision/select
badge
dropdown-menu
popover
command

Props

columns* = —
ColumnDef<TData, TValue>[]

TanStack column definitions (accessorKey, header, cell).

data* = —
TData[]

The row data.

toolbar = —
(table) => React.ReactNode

Optional toolbar rendered above the table; receives the table instance for search, faceted filters, and view options.

A reusable Data Table built on @tanstack/react-table over the Table primitive. <DataTable columns data /> wires up sorting, filtering, pagination, row selection, column visibility, and faceting; pass a toolbar render-prop for search/facets/view-options. Ships with DataTableColumnHeader, DataTablePagination, DataTableViewOptions, DataTableFacetedFilter, and DataTableRowActions helpers. The menu-driven pieces (column header, view options, row actions) use Dropdown Menu; faceted filters use Popover + Command.

Copied!
components/ui/data-table.tsx
"use client"

export { DataTableColumnHeader } from "./data-table-column-header"
export { DataTablePagination } from "./data-table-pagination"
export { DataTableViewOptions } from "./data-table-view-options"
export { DataTableFacetedFilter } from "./data-table-faceted-filter"
export { DataTableRowActions } from "./data-table-row-actions"

import * as React from "react"
import {
  type ColumnDef,
  type ColumnFiltersState,
  type SortingState,
  type Table as TanStackTable,
  type VisibilityState,
  flexRender,
  getCoreRowModel,
  getFacetedRowModel,
  getFacetedUniqueValues,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table"

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { DataTablePagination } from "./data-table-pagination"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
  /** Optional toolbar rendered above the table; receives the table instance. */
  toolbar?: (table: TanStackTable<TData>) => React.ReactNode
}

export function DataTable<TData, TValue>({
  columns,
  data,
  toolbar,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    []
  )
  const [columnVisibility, setColumnVisibility] =
    React.useState<VisibilityState>({})
  const [rowSelection, setRowSelection] = React.useState({})

  const table = useReactTable({
    data,
    columns,
    state: { sorting, columnFilters, columnVisibility, rowSelection },
    enableRowSelection: true,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
  })

  return (
    <div className="space-y-4">
      {toolbar?.(table)}
      <div className="rounded-md border">
        <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 ? (
              table.getRowModel().rows.map((row) => (
                <TableRow
                  key={row.id}
                  data-state={row.getIsSelected() && "selected"}
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <DataTablePagination table={table} />
    </div>
  )
}
components/ui/data-table-pagination.tsx
"use client"

import {
  ChevronLeftIcon,
  ChevronRightIcon,
  ChevronsLeftIcon,
  ChevronsRightIcon,
} from "lucide-react"
import { type Table } from "@tanstack/react-table"

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

interface DataTablePaginationProps<TData> {
  table: Table<TData>
  pageSizeOptions?: number[]
}

export function DataTablePagination<TData>({
  table,
  pageSizeOptions = [5, 10, 20, 30],
}: DataTablePaginationProps<TData>) {
  return (
    <div className="flex flex-wrap items-center justify-between gap-4 px-2">
      <div className="text-muted-foreground flex-1 text-sm whitespace-nowrap">
        {table.getFilteredSelectedRowModel().rows.length} of{" "}
        {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className="flex items-center gap-4 lg:gap-8">
        <div className="flex items-center gap-2">
          <p className="text-sm font-medium">Rows per page</p>
          <Select
            value={`${table.getState().pagination.pageSize}`}
            onValueChange={(value) => table.setPageSize(Number(value))}
          >
            <SelectTrigger className="h-8 w-[70px]">
              <SelectValue placeholder={table.getState().pagination.pageSize} />
            </SelectTrigger>
            <SelectContent side="top">
              {pageSizeOptions.map((size) => (
                <SelectItem key={size} value={`${size}`}>
                  {size}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex w-[100px] items-center justify-center text-sm font-medium">
          Page {table.getState().pagination.pageIndex + 1} of{" "}
          {table.getPageCount()}
        </div>
        <div className="flex items-center gap-2">
          <Button
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
            aria-label="Go to first page"
          >
            <ChevronsLeftIcon />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            aria-label="Go to previous page"
          >
            <ChevronLeftIcon />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            aria-label="Go to next page"
          >
            <ChevronRightIcon />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
            aria-label="Go to last page"
          >
            <ChevronsRightIcon />
          </Button>
        </div>
      </div>
    </div>
  )
}
components/ui/data-table-column-header.tsx
"use client"

import {
  ArrowDownIcon,
  ArrowUpIcon,
  ChevronsUpDownIcon,
  EyeOffIcon,
} from "lucide-react"
import { type Column } from "@tanstack/react-table"

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

interface DataTableColumnHeaderProps<TData, TValue>
  extends React.HTMLAttributes<HTMLDivElement> {
  column: Column<TData, TValue>
  title: string
}

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

  return (
    <div className={cn("flex items-center gap-2", className)}>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="sm"
            className="data-[state=open]:bg-accent -ml-3 h-8"
          >
            <span>{title}</span>
            {column.getIsSorted() === "desc" ? (
              <ArrowDownIcon />
            ) : column.getIsSorted() === "asc" ? (
              <ArrowUpIcon />
            ) : (
              <ChevronsUpDownIcon />
            )}
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
            <ArrowUpIcon className="text-muted-foreground/70" />
            Asc
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
            <ArrowDownIcon className="text-muted-foreground/70" />
            Desc
          </DropdownMenuItem>
          {column.getCanHide() && (
            <>
              <DropdownMenuSeparator />
              <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
                <EyeOffIcon className="text-muted-foreground/70" />
                Hide
              </DropdownMenuItem>
            </>
          )}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  )
}
components/ui/data-table-view-options.tsx
"use client"

import { Settings2Icon } from "lucide-react"
import { type Table } from "@tanstack/react-table"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

interface DataTableViewOptionsProps<TData> {
  table: Table<TData>
}

export function DataTableViewOptions<TData>({
  table,
}: DataTableViewOptionsProps<TData>) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="sm" className="ml-auto h-8">
          <Settings2Icon />
          View
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-[150px]">
        <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {table
          .getAllColumns()
          .filter(
            (column) =>
              typeof column.accessorFn !== "undefined" && column.getCanHide()
          )
          .map((column) => (
            <DropdownMenuCheckboxItem
              key={column.id}
              className="capitalize"
              checked={column.getIsVisible()}
              onCheckedChange={(value) => column.toggleVisibility(!!value)}
            >
              {column.id}
            </DropdownMenuCheckboxItem>
          ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
components/ui/data-table-faceted-filter.tsx
"use client"

import * as React from "react"
import { CheckIcon, PlusCircleIcon } from "lucide-react"
import { type Column } from "@tanstack/react-table"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

interface FacetedFilterOption {
  label: string
  value: string
  icon?: React.ComponentType<{ className?: string }>
}

interface DataTableFacetedFilterProps<TData, TValue> {
  column?: Column<TData, TValue>
  title?: string
  options: FacetedFilterOption[]
}

export function DataTableFacetedFilter<TData, TValue>({
  column,
  title,
  options,
}: DataTableFacetedFilterProps<TData, TValue>) {
  const facets = column?.getFacetedUniqueValues()
  const selectedValues = new Set(column?.getFilterValue() as string[])

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline" size="sm" className="h-8 border-dashed">
          <PlusCircleIcon />
          {title}
          {selectedValues?.size > 0 && (
            <>
              <div className="bg-border mx-2 h-4 w-px" />
              <Badge
                variant="secondary"
                className="rounded-sm px-1 font-normal lg:hidden"
              >
                {selectedValues.size}
              </Badge>
              <div className="hidden gap-1 lg:flex">
                {selectedValues.size > 2 ? (
                  <Badge
                    variant="secondary"
                    className="rounded-sm px-1 font-normal"
                  >
                    {selectedValues.size} selected
                  </Badge>
                ) : (
                  options
                    .filter((option) => selectedValues.has(option.value))
                    .map((option) => (
                      <Badge
                        variant="secondary"
                        key={option.value}
                        className="rounded-sm px-1 font-normal"
                      >
                        {option.label}
                      </Badge>
                    ))
                )}
              </div>
            </>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0" align="start">
        <Command>
          <CommandInput placeholder={title} />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selectedValues.has(option.value)
                return (
                  <CommandItem
                    key={option.value}
                    onSelect={() => {
                      if (isSelected) {
                        selectedValues.delete(option.value)
                      } else {
                        selectedValues.add(option.value)
                      }
                      const filterValues = Array.from(selectedValues)
                      column?.setFilterValue(
                        filterValues.length ? filterValues : undefined
                      )
                    }}
                  >
                    <div
                      className={cn(
                        "flex size-4 items-center justify-center rounded-[4px] border",
                        isSelected
                          ? "bg-primary border-primary text-primary-foreground"
                          : "border-input [&_svg]:invisible"
                      )}
                    >
                      <CheckIcon className="size-3.5" />
                    </div>
                    {option.icon && (
                      <option.icon className="text-muted-foreground size-4" />
                    )}
                    <span>{option.label}</span>
                    {facets?.get(option.value) && (
                      <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
                        {facets.get(option.value)}
                      </span>
                    )}
                  </CommandItem>
                )
              })}
            </CommandGroup>
            {selectedValues.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem
                    onSelect={() => column?.setFilterValue(undefined)}
                    className="justify-center text-center"
                  >
                    Clear filters
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}
components/ui/data-table-row-actions.tsx
"use client"

import { MoreHorizontalIcon } from "lucide-react"
import { type Row } from "@tanstack/react-table"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

interface DataTableRowActionsProps<TData> {
  row: Row<TData>
  onEdit?: (row: Row<TData>) => void
  onDelete?: (row: Row<TData>) => void
}

export function DataTableRowActions<TData>({
  row,
  onEdit,
  onDelete,
}: DataTableRowActionsProps<TData>) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant="ghost"
          size="icon"
          className="data-[state=open]:bg-muted size-8"
        >
          <MoreHorizontalIcon />
          <span className="sr-only">Open menu</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end" className="w-[160px]">
        <DropdownMenuItem onClick={() => onEdit?.(row)}>Edit</DropdownMenuItem>
        <DropdownMenuItem>Make a copy</DropdownMenuItem>
        <DropdownMenuItem>Favorite</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive" onClick={() => onDelete?.(row)}>
          Delete
          <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Examples

Data Table

The reusable `` over TanStack Table — sortable column-header menus, pagination, and an empty state out of the box.

Packages

@designrevision/data-table

Props

No props documented yet.

Copied!
components/ui/data-table-demo-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"

import { DataTable, DataTableColumnHeader } from "@/components/ui/data-table"

type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}

const data: Payment[] = [
  { id: "1", amount: 316, status: "success", email: "[email protected]" },
  { id: "2", amount: 242, status: "success", email: "[email protected]" },
  { id: "3", amount: 837, status: "processing", email: "[email protected]" },
  { id: "4", amount: 874, status: "success", email: "[email protected]" },
  { id: "5", amount: 721, status: "failed", email: "[email protected]" },
  { id: "6", amount: 459, status: "pending", email: "[email protected]" },
  { id: "7", amount: 633, status: "processing", email: "[email protected]" },
  { id: "8", amount: 192, status: "success", email: "[email protected]" },
  { id: "9", amount: 548, status: "failed", email: "[email protected]" },
  { id: "10", amount: 387, status: "pending", email: "[email protected]" },
  { id: "11", amount: 905, status: "success", email: "[email protected]" },
  { id: "12", amount: 264, status: "processing", email: "[email protected]" },
]

const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <span className="capitalize">{row.getValue("status")}</span>,
  },
  {
    accessorKey: "email",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
  },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"))
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(amount)
      return <div className="text-right font-medium tabular-nums">{formatted}</div>
    },
  },
]

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

Row selection

A [Checkbox](/components/checkbox) column with an indeterminate select-all, wired through TanStack's row-selection state and the pagination count.

Packages

@designrevision/data-table
checkbox

Props

No props documented yet.

Copied!
components/ui/data-table-selection-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"

import { Checkbox } from "@/components/ui/checkbox"
import { DataTable, DataTableColumnHeader } from "@/components/ui/data-table"

type User = {
  id: string
  name: string
  email: string
  role: "owner" | "member" | "viewer"
}

const data: User[] = [
  { id: "1", name: "Ada Lovelace", email: "[email protected]", role: "owner" },
  { id: "2", name: "Alan Turing", email: "[email protected]", role: "member" },
  { id: "3", name: "Grace Hopper", email: "[email protected]", role: "member" },
  { id: "4", name: "Katherine Johnson", email: "[email protected]", role: "viewer" },
  { id: "5", name: "Margaret Hamilton", email: "[email protected]", role: "member" },
  { id: "6", name: "Hedy Lamarr", email: "[email protected]", role: "viewer" },
  { id: "7", name: "Dorothy Vaughan", email: "[email protected]", role: "member" },
  { id: "8", name: "Radia Perlman", email: "[email protected]", role: "owner" },
  { id: "9", name: "Barbara Liskov", email: "[email protected]", role: "member" },
  { id: "10", name: "Frances Allen", email: "[email protected]", role: "viewer" },
  { id: "11", name: "Shafi Goldwasser", email: "[email protected]", role: "member" },
  { id: "12", name: "Carol Shaw", email: "[email protected]", role: "viewer" },
]

const columns: ColumnDef<User>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() && "indeterminate")
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(!!value)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: "name",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
    cell: ({ row }) => <span className="font-medium">{row.getValue("name")}</span>,
  },
  {
    accessorKey: "email",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
  },
  {
    accessorKey: "role",
    header: "Role",
    cell: ({ row }) => <span className="capitalize">{row.getValue("role")}</span>,
  },
]

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

Filter toolbar

A toolbar with a column-search input and a reset button — column filtering via `getFilteredRowModel`.

Packages

lucide-react
@designrevision/data-table
button
input

Props

No props documented yet.

Copied!
components/ui/data-table-filter-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"
import { XIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { DataTable, DataTableColumnHeader } from "@/components/ui/data-table"
import { Input } from "@/components/ui/input"

type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}

const data: Payment[] = [
  { id: "1", amount: 316, status: "success", email: "[email protected]" },
  { id: "2", amount: 242, status: "success", email: "[email protected]" },
  { id: "3", amount: 837, status: "processing", email: "[email protected]" },
  { id: "4", amount: 874, status: "success", email: "[email protected]" },
  { id: "5", amount: 721, status: "failed", email: "[email protected]" },
  { id: "6", amount: 459, status: "pending", email: "[email protected]" },
  { id: "7", amount: 633, status: "processing", email: "[email protected]" },
  { id: "8", amount: 192, status: "success", email: "[email protected]" },
  { id: "9", amount: 548, status: "failed", email: "[email protected]" },
  { id: "10", amount: 387, status: "pending", email: "[email protected]" },
  { id: "11", amount: 905, status: "success", email: "[email protected]" },
  { id: "12", amount: 264, status: "processing", email: "[email protected]" },
]

const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <span className="capitalize">{row.getValue("status")}</span>,
  },
  {
    accessorKey: "email",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
  },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"))
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(amount)
      return <div className="text-right font-medium tabular-nums">{formatted}</div>
    },
  },
]

export default function DataTableFilter01() {
  return (
    <DataTable
      columns={columns}
      data={data}
      toolbar={(table) => {
        const isFiltered = table.getState().columnFilters.length > 0
        return (
          <div className="flex items-center gap-2">
            <Input
              placeholder="Filter emails…"
              value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
              onChange={(event) =>
                table.getColumn("email")?.setFilterValue(event.target.value)
              }
              className="h-8 w-[180px] lg:w-[250px]"
            />
            {isFiltered && (
              <Button
                variant="ghost"
                size="sm"
                className="h-8 px-2 lg:px-3"
                onClick={() => table.resetColumnFilters()}
              >
                Reset
                <XIcon />
              </Button>
            )}
          </div>
        )
      }}
    />
  )
}

Column visibility

A View-options menu to show and hide columns — built on [Dropdown Menu](/components/dropdown-menu) checkbox items.

Packages

@designrevision/data-table

Props

No props documented yet.

Copied!
components/ui/data-table-visibility-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"

import {
  DataTable,
  DataTableColumnHeader,
  DataTableViewOptions,
} from "@/components/ui/data-table"

type User = {
  id: string
  name: string
  email: string
  role: "owner" | "member" | "viewer"
  team: string
}

const data: User[] = [
  { id: "1", name: "Ada Lovelace", email: "[email protected]", role: "owner", team: "Engineering" },
  { id: "2", name: "Alan Turing", email: "[email protected]", role: "member", team: "Research" },
  { id: "3", name: "Grace Hopper", email: "[email protected]", role: "member", team: "Engineering" },
  { id: "4", name: "Katherine Johnson", email: "[email protected]", role: "viewer", team: "Research" },
  { id: "5", name: "Margaret Hamilton", email: "[email protected]", role: "member", team: "Apollo" },
  { id: "6", name: "Hedy Lamarr", email: "[email protected]", role: "viewer", team: "Comms" },
  { id: "7", name: "Dorothy Vaughan", email: "[email protected]", role: "member", team: "Research" },
  { id: "8", name: "Radia Perlman", email: "[email protected]", role: "owner", team: "Networking" },
  { id: "9", name: "Barbara Liskov", email: "[email protected]", role: "member", team: "Engineering" },
  { id: "10", name: "Frances Allen", email: "[email protected]", role: "viewer", team: "Compilers" },
  { id: "11", name: "Shafi Goldwasser", email: "[email protected]", role: "member", team: "Security" },
  { id: "12", name: "Carol Shaw", email: "[email protected]", role: "viewer", team: "Games" },
]

const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
    cell: ({ row }) => <span className="font-medium">{row.getValue("name")}</span>,
  },
  {
    accessorKey: "email",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
  },
  {
    accessorKey: "role",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Role" />,
    cell: ({ row }) => <span className="capitalize">{row.getValue("role")}</span>,
  },
  {
    accessorKey: "team",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Team" />,
  },
]

export default function DataTableVisibility01() {
  return (
    <DataTable
      columns={columns}
      data={data}
      toolbar={(table) => (
        <div className="flex items-center justify-end">
          <DataTableViewOptions table={table} />
        </div>
      )}
    />
  )
}

Faceted filters

A multi-select facet ([Popover](/components/popover) + [Command](/components/command)) with live option counts and selected-value badges — the classic data-table filter.

Packages

lucide-react
@designrevision/data-table

Props

No props documented yet.

Copied!
components/ui/data-table-faceted-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"
import {
  CircleCheckIcon,
  CircleDashedIcon,
  CircleDotIcon,
  CircleIcon,
  CircleXIcon,
} from "lucide-react"

import {
  DataTable,
  DataTableColumnHeader,
  DataTableFacetedFilter,
} from "@/components/ui/data-table"

const statuses = [
  { value: "backlog", label: "Backlog", icon: CircleDashedIcon },
  { value: "todo", label: "Todo", icon: CircleIcon },
  { value: "in progress", label: "In Progress", icon: CircleDotIcon },
  { value: "done", label: "Done", icon: CircleCheckIcon },
  { value: "canceled", label: "Canceled", icon: CircleXIcon },
]

type Task = { id: string; title: string; status: string }

const data: Task[] = [
  { id: "TASK-8782", title: "Refactor the auth middleware", status: "in progress" },
  { id: "TASK-7878", title: "Add dark mode toggle", status: "backlog" },
  { id: "TASK-7839", title: "Fix the pagination off-by-one", status: "done" },
  { id: "TASK-5562", title: "Write the onboarding docs", status: "todo" },
  { id: "TASK-8686", title: "Migrate to the new API", status: "canceled" },
  { id: "TASK-1280", title: "Compress hero images", status: "done" },
  { id: "TASK-7262", title: "Audit the color tokens", status: "in progress" },
  { id: "TASK-1138", title: "Set up the staging env", status: "todo" },
  { id: "TASK-7184", title: "Remove the legacy flags", status: "backlog" },
  { id: "TASK-5160", title: "Add e2e tests for checkout", status: "todo" },
  { id: "TASK-5618", title: "Tune the cache headers", status: "done" },
  { id: "TASK-6699", title: "Draft the Q3 roadmap", status: "in progress" },
]

const columns: ColumnDef<Task>[] = [
  {
    accessorKey: "id",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Task" />,
    cell: ({ row }) => (
      <span className="text-muted-foreground font-mono text-xs">
        {row.getValue("id")}
      </span>
    ),
  },
  {
    accessorKey: "title",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />,
    cell: ({ row }) => <span className="font-medium">{row.getValue("title")}</span>,
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => {
      const status = statuses.find((s) => s.value === row.getValue("status"))
      if (!status) return null
      return (
        <div className="flex items-center gap-2">
          <status.icon className="text-muted-foreground size-4" />
          <span>{status.label}</span>
        </div>
      )
    },
    filterFn: (row, id, value) => (value as string[]).includes(row.getValue(id)),
  },
]

export default function DataTableFaceted01() {
  return (
    <DataTable
      columns={columns}
      data={data}
      toolbar={(table) => (
        <div className="flex items-center gap-2">
          <DataTableFacetedFilter
            column={table.getColumn("status")}
            title="Status"
            options={statuses}
          />
        </div>
      )}
    />
  )
}

Row actions

A per-row "⋯" actions menu ([Dropdown Menu](/components/dropdown-menu)) with edit, copy, and a destructive delete.

Packages

@designrevision/data-table
badge

Props

No props documented yet.

Copied!
components/ui/data-table-row-actions-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"

import { Badge } from "@/components/ui/badge"
import {
  DataTable,
  DataTableColumnHeader,
  DataTableRowActions,
} from "@/components/ui/data-table"

type Task = {
  id: string
  title: string
  type: "Bug" | "Feature" | "Documentation"
  status: "todo" | "in progress" | "done"
}

const data: Task[] = [
  { id: "TASK-8782", title: "Refactor the auth middleware", type: "Bug", status: "in progress" },
  { id: "TASK-7878", title: "Add dark mode toggle", type: "Feature", status: "todo" },
  { id: "TASK-7839", title: "Fix the pagination off-by-one", type: "Bug", status: "done" },
  { id: "TASK-5562", title: "Write the onboarding docs", type: "Documentation", status: "todo" },
  { id: "TASK-1280", title: "Compress hero images", type: "Feature", status: "done" },
  { id: "TASK-7262", title: "Audit the color tokens", type: "Documentation", status: "in progress" },
  { id: "TASK-1138", title: "Set up the staging env", type: "Feature", status: "todo" },
  { id: "TASK-5160", title: "Add e2e tests for checkout", type: "Bug", status: "todo" },
  { id: "TASK-5618", title: "Tune the cache headers", type: "Feature", status: "done" },
  { id: "TASK-6699", title: "Draft the Q3 roadmap", type: "Documentation", status: "in progress" },
  { id: "TASK-2342", title: "Patch the XSS in comments", type: "Bug", status: "todo" },
  { id: "TASK-9081", title: "Localize the checkout flow", type: "Feature", status: "todo" },
]

const columns: ColumnDef<Task>[] = [
  {
    accessorKey: "id",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Task" />,
    cell: ({ row }) => (
      <span className="text-muted-foreground font-mono text-xs">
        {row.getValue("id")}
      </span>
    ),
  },
  {
    accessorKey: "title",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />,
    cell: ({ row }) => (
      <div className="flex items-center gap-2">
        <Badge variant="outline">{row.original.type}</Badge>
        <span className="font-medium">{row.getValue("title")}</span>
      </div>
    ),
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => <span className="capitalize">{row.getValue("status")}</span>,
  },
  {
    id: "actions",
    cell: ({ row }) => (
      <div className="text-right">
        <DataTableRowActions row={row} />
      </div>
    ),
    enableSorting: false,
    enableHiding: false,
  },
]

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

Full tasks table

The complete showcase — selection, sortable headers, status & priority faceted filters, global search, column visibility, row actions, and pagination together.

Packages

lucide-react
@designrevision/data-table
badge
checkbox
input
button

Props

No props documented yet.

Copied!
components/ui/data-table-full-01.tsx
"use client"

import { type ColumnDef } from "@tanstack/react-table"
import {
  ArrowDownIcon,
  ArrowRightIcon,
  ArrowUpIcon,
  CircleCheckIcon,
  CircleDashedIcon,
  CircleDotIcon,
  CircleIcon,
  CircleXIcon,
  XIcon,
} from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  DataTable,
  DataTableColumnHeader,
  DataTableFacetedFilter,
  DataTableRowActions,
  DataTableViewOptions,
} from "@/components/ui/data-table"
import { Input } from "@/components/ui/input"

const statuses = [
  { value: "backlog", label: "Backlog", icon: CircleDashedIcon },
  { value: "todo", label: "Todo", icon: CircleIcon },
  { value: "in progress", label: "In Progress", icon: CircleDotIcon },
  { value: "done", label: "Done", icon: CircleCheckIcon },
  { value: "canceled", label: "Canceled", icon: CircleXIcon },
]

const priorities = [
  { value: "low", label: "Low", icon: ArrowDownIcon },
  { value: "medium", label: "Medium", icon: ArrowRightIcon },
  { value: "high", label: "High", icon: ArrowUpIcon },
]

type Task = {
  id: string
  title: string
  type: "Bug" | "Feature" | "Documentation"
  status: string
  priority: string
}

const data: Task[] = [
  { id: "TASK-8782", title: "Refactor the auth middleware", type: "Bug", status: "in progress", priority: "high" },
  { id: "TASK-7878", title: "Add dark mode toggle", type: "Feature", status: "backlog", priority: "medium" },
  { id: "TASK-7839", title: "Fix the pagination off-by-one", type: "Bug", status: "done", priority: "high" },
  { id: "TASK-5562", title: "Write the onboarding docs", type: "Documentation", status: "todo", priority: "low" },
  { id: "TASK-8686", title: "Migrate to the new API", type: "Feature", status: "canceled", priority: "medium" },
  { id: "TASK-1280", title: "Compress hero images", type: "Feature", status: "done", priority: "low" },
  { id: "TASK-7262", title: "Audit the color tokens", type: "Documentation", status: "in progress", priority: "medium" },
  { id: "TASK-1138", title: "Set up the staging env", type: "Feature", status: "todo", priority: "high" },
  { id: "TASK-7184", title: "Remove the legacy flags", type: "Bug", status: "backlog", priority: "low" },
  { id: "TASK-5160", title: "Add e2e tests for checkout", type: "Bug", status: "todo", priority: "high" },
  { id: "TASK-5618", title: "Tune the cache headers", type: "Feature", status: "done", priority: "medium" },
  { id: "TASK-6699", title: "Draft the Q3 roadmap", type: "Documentation", status: "in progress", priority: "medium" },
  { id: "TASK-2342", title: "Patch the XSS in comments", type: "Bug", status: "todo", priority: "high" },
  { id: "TASK-9081", title: "Localize the checkout flow", type: "Feature", status: "todo", priority: "low" },
  { id: "TASK-4123", title: "Add rate limiting to the API", type: "Feature", status: "backlog", priority: "medium" },
  { id: "TASK-3377", title: "Document the deploy runbook", type: "Documentation", status: "done", priority: "low" },
]

const columns: ColumnDef<Task>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() && "indeterminate")
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(!!value)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: "id",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Task" />,
    cell: ({ row }) => (
      <span className="text-muted-foreground font-mono text-xs">
        {row.getValue("id")}
      </span>
    ),
    enableHiding: false,
  },
  {
    accessorKey: "title",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />,
    cell: ({ row }) => (
      <div className="flex items-center gap-2">
        <Badge variant="outline">{row.original.type}</Badge>
        <span className="max-w-[320px] truncate font-medium">
          {row.getValue("title")}
        </span>
      </div>
    ),
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => {
      const status = statuses.find((s) => s.value === row.getValue("status"))
      if (!status) return null
      return (
        <div className="flex items-center gap-2">
          <status.icon className="text-muted-foreground size-4" />
          <span>{status.label}</span>
        </div>
      )
    },
    filterFn: (row, id, value) => (value as string[]).includes(row.getValue(id)),
  },
  {
    accessorKey: "priority",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Priority" />,
    cell: ({ row }) => {
      const priority = priorities.find((p) => p.value === row.getValue("priority"))
      if (!priority) return null
      return (
        <div className="flex items-center gap-2">
          <priority.icon className="text-muted-foreground size-4" />
          <span>{priority.label}</span>
        </div>
      )
    },
    filterFn: (row, id, value) => (value as string[]).includes(row.getValue(id)),
  },
  {
    id: "actions",
    cell: ({ row }) => (
      <div className="text-right">
        <DataTableRowActions row={row} />
      </div>
    ),
    enableSorting: false,
    enableHiding: false,
  },
]

export default function DataTableFull01() {
  return (
    <DataTable
      columns={columns}
      data={data}
      toolbar={(table) => {
        const isFiltered = table.getState().columnFilters.length > 0
        return (
          <div className="flex flex-wrap items-center gap-2">
            <Input
              placeholder="Filter tasks…"
              value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
              onChange={(event) =>
                table.getColumn("title")?.setFilterValue(event.target.value)
              }
              className="h-8 w-[150px] lg:w-[250px]"
            />
            <DataTableFacetedFilter
              column={table.getColumn("status")}
              title="Status"
              options={statuses}
            />
            <DataTableFacetedFilter
              column={table.getColumn("priority")}
              title="Priority"
              options={priorities}
            />
            {isFiltered && (
              <Button
                variant="ghost"
                size="sm"
                className="h-8 px-2 lg:px-3"
                onClick={() => table.resetColumnFilters()}
              >
                Reset
                <XIcon />
              </Button>
            )}
            <DataTableViewOptions table={table} />
          </div>
        )
      }}
    />
  )
}

Frequently asked questions

Yes — it follows the canonical shadcn Data Table pattern, built on @tanstack/react-table over the Table primitive. Official ships it as a step-by-step guide; we package it as a reusable plus the standard helpers (column header, pagination, view options, faceted filter, row actions), so you can drop it in and add features incrementally.
Use the static [Table](/components/table) when you control the markup and only need light interactivity (it does sorting, selection, and pagination in plain React state with no dependency). Reach for the Data Table when you have a column-driven dataset that needs sorting, filtering, faceted filters, column visibility, and pagination from one engine — that engine is TanStack Table.
TanStack Table is a headless table library — it owns the data logic (row models for sorting, filtering, pagination, faceting, selection, expansion) and leaves the markup to you. The shadcn Data Table renders that logic through the styled Table primitive. Install it with npm install @tanstack/react-table.
Render the column's header with the DataTableColumnHeader helper — header: ({ column }) => . It shows a small menu with Asc / Desc / Hide, backed by getSortedRowModel(). Set enableSorting: false on a column to opt out.
A faceted filter is a multi-select dropdown (Popover + Command) bound to a column: . Give the column a filterFn that checks array membership — (row, id, value) => value.includes(row.getValue(id)) — and enable getFacetedUniqueValues() so the option counts show. The Faceted filters and Full tasks table examples show it.
Yes. TanStack Table supports manual mode: set manualSorting / manualFiltering / manualPagination to true, drive the state from your query, and fetch the matching page from your API instead of using the client row models. The wrapper here is client-side (good for hundreds to a few thousand rows); switch to manual mode for large or server-paged data.
Both use [Dropdown Menu](/components/dropdown-menu). For row actions, add an actions column whose cell renders . For column visibility, drop in the toolbar. The Row actions, Column visibility, and Full tasks table examples cover them.