---
title: Data Table
description: Build sortable, filterable tables with TanStack Table.
group: Components
order: 30
---

Build data grids with TanStack Table and the arctis `<Table />` primitives. Add selection, sorting, filtering, visibility, and pagination as needed.

```tsx
"use client"

import * as React from "react"
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnFiltersState,
  type SortingState,
  type VisibilityState,
} from "@tanstack/react-table"
import {
  DataTable,
  DataTablePagination,
  DataTableViewOptions,
} from "@/components/ui/data-table"
import { Input } from "@/components/ui/input"
import { columns } from "./columns"
import { payments } from "./payments"

export function Example() {
  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: payments,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection,
    },
  })

  return (
    <div className="w-full space-y-4">
      <div className="flex w-full items-center justify-between gap-2">
        <Input
          placeholder="Filter emails..."
          value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
          onChange={(event) =>
            table.getColumn("email")?.setFilterValue(event.target.value)
          }
          className="w-auto max-w-sm"
        />
        <DataTableViewOptions table={table} />
      </div>
      <DataTable columns={columns} table={table} />
      <DataTablePagination table={table} />
    </div>
  )
}
```

## Introduction

Tables rarely need the same columns, filters, or data source. Instead of working around a rigid component, compose the table you need with TanStack Table and `<Table />`.

Start with column definitions, then add selection, sorting, filtering, visibility, pagination, and row actions. The reusable pieces live in `@arctis-sh/ui/data-table`.

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add data-table
```

This adds [Table](/docs/components/table), [Button](/docs/components/button), [Dropdown Menu](/docs/components/dropdown-menu), [Native Select](/docs/components/native-select), [Checkbox](/docs/components/checkbox), and [Input](/docs/components/input), then installs the `@tanstack/react-table` peer.

## Usage

```tsx
"use client"

import {
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table"
import { DataTable } from "@/components/ui/data-table"
```

```tsx
const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
})

return <DataTable columns={columns} table={table} />
```

## Composition

```tree
div
├── Input
├── DataTableViewOptions
├── DataTable
│   └── Table
│       ├── TableHeader
│       │   └── TableRow
│       │       └── TableHead
│       └── TableBody
│           └── TableRow
│               └── TableCell
└── DataTablePagination
```

## Prerequisites

The examples below use these payment rows:

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

const payments: Payment[] = [
  {
    id: "pay_8k2n1q",
    amount: 316,
    status: "success",
    email: "mina@example.com",
  },
  // ...
]
```

## Column definitions

Column definitions control structure, formatting, sorting, and filtering.

```tsx
"use client"

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

export const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: "status",
    header: "Status",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = Number.parseFloat(row.getValue("amount"))
      const formatted = new Intl.NumberFormat("en-US", {
        style: "currency",
        currency: "USD",
      }).format(amount)

      return <div className="text-right font-medium">{formatted}</div>
    },
  },
]
```

## Row actions

Add an actions column with a [Dropdown Menu](/docs/components/dropdown-menu).

```tsx
{
  id: "actions",
  enableHiding: false,
  cell: ({ row }) => {
    const payment = row.original

    return (
      <DropdownMenu>
        <DropdownMenuTrigger
          className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
          aria-label="Open menu"
        >
          <span className="sr-only">Open menu</span>
          {/* icon */}
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuLabel>Actions</DropdownMenuLabel>
          <DropdownMenuItem
            onClick={() => navigator.clipboard.writeText(payment.id)}
          >
            Copy payment ID
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem>View customer</DropdownMenuItem>
          <DropdownMenuItem>View payment details</DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    )
  },
}
```

## Pagination

Enable TanStack pagination, then render `DataTablePagination`.

```tsx
const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
})
```

```tsx
<DataTable columns={columns} table={table} />
<DataTablePagination table={table} />
```

## Sorting

Track sorting state and use `DataTableColumnHeader` for a sortable header.

```tsx
const [sorting, setSorting] = React.useState<SortingState>([])

const table = useReactTable({
  data,
  columns,
  onSortingChange: setSorting,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  state: { sorting },
})
```

```tsx
{
  accessorKey: "email",
  header: ({ column }) => (
    <DataTableColumnHeader column={column} title="Email" />
  ),
}
```

## Filtering

Filter a column with a controlled [Input](/docs/components/input).

```tsx
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])

const table = useReactTable({
  data,
  columns,
  onColumnFiltersChange: setColumnFilters,
  getFilteredRowModel: getFilteredRowModel(),
  getCoreRowModel: getCoreRowModel(),
  state: { columnFilters },
})
```

```tsx
<Input
  placeholder="Filter emails..."
  value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
  onChange={(event) =>
    table.getColumn("email")?.setFilterValue(event.target.value)
  }
  className="w-auto max-w-sm"
/>
```

## Visibility

Toggle columns with `DataTableViewOptions`. Hidden columns keep their width so the layout does not jump.

```tsx
const [columnVisibility, setColumnVisibility] =
  React.useState<VisibilityState>({})

const table = useReactTable({
  data,
  columns,
  onColumnVisibilityChange: setColumnVisibility,
  getCoreRowModel: getCoreRowModel(),
  state: { columnVisibility },
})
```

```tsx
<div className="flex w-full items-center justify-between gap-2">
  <Input className="w-auto max-w-sm" placeholder="Filter emails..." />
  <DataTableViewOptions table={table} />
</div>
```

## Row Selection

Add a select column with [Checkbox](/docs/components/checkbox), then track `rowSelection`.

```tsx
{
  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,
}
```

```tsx
const [rowSelection, setRowSelection] = React.useState({})

const table = useReactTable({
  data,
  columns,
  onRowSelectionChange: setRowSelection,
  getCoreRowModel: getCoreRowModel(),
  state: { rowSelection },
})
```

`DataTablePagination` shows the number of selected rows.

## API Reference

### DataTable

Renders the bordered table from a TanStack `table` instance. Hidden columns stay in the layout at zero opacity so siblings do not shift.

| Prop | Type | Default |
| --- | --- | --- |
| columns | ColumnDef<TData, TValue>[] | — |
| table | Table<TData> | — |
| emptyMessage | string | "No results." |
| className | string | — |

```tsx
<DataTable columns={columns} table={table} />
```

### DataTableColumnHeader

Sortable, hideable column header. Renders plain text when the column cannot sort.

| Prop | Type | Default |
| --- | --- | --- |
| column | Column<TData, TValue> | — |
| title | string | — |
| className | string | — |

```tsx
<DataTableColumnHeader column={column} title="Email" />
```

### DataTablePagination

Provides page size, current page, first / previous / next / last navigation, and the selected row count.

| Prop | Type | Default |
| --- | --- | --- |
| table | Table<TData> | — |
| className | string | — |

```tsx
<DataTablePagination table={table} />
```

### DataTableViewOptions

Dropdown for toggling hideable columns. Its height and surface match [Input](/docs/components/input).

| Prop | Type | Default |
| --- | --- | --- |
| table | Table<TData> | — |
| className | string | — |

```tsx
<DataTableViewOptions table={table} />
```

## TanStack Table

`DataTable` is a thin layout layer. Sorting, filtering, pagination, and selection come from [TanStack Table](https://tanstack.com/table/latest/docs/introduction). Its documentation covers the full headless API, including `useReactTable`, row models, and column helpers.

## Source

```tsx
"use client";

import type { ComponentProps, HTMLAttributes } from "react";
import {
  flexRender,
  type Column,
  type ColumnDef,
  type Table as TanstackTable,
} from "@tanstack/react-table";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  NativeSelect,
  NativeSelectOption,
} from "@/components/ui/native-select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";

type DataTableProps<TData, TValue> = {
  columns: ColumnDef<TData, TValue>[];
  table: TanstackTable<TData>;
  emptyMessage?: string;
  className?: string;
};

function DataTable<TData, TValue>({
  columns,
  table,
  emptyMessage = "No results.",
  className,
}: DataTableProps<TData, TValue>) {
  const leafColumns = table.getAllLeafColumns();

  return (
    <div
      className={cn(
        "overflow-hidden rounded-md border border-border",
        className,
      )}
    >
      <Table className="table-fixed">
        <colgroup>
          {leafColumns.map((column) => (
            <col
              key={column.id}
              style={{
                width: column.getSize(),
                minWidth: column.getSize(),
              }}
            />
          ))}
        </colgroup>
        <TableHeader>
          <TableRow className="h-12 hover:bg-transparent">
            {leafColumns.map((column) => {
              const visible = column.getIsVisible();

              return (
                <TableHead
                  key={column.id}
                  className={cn(
                    "h-12 py-0 transition-opacity duration-150 ease-out",
                    !visible && "pointer-events-none opacity-0",
                  )}
                  aria-hidden={!visible}
                >
                  {flexRender(column.columnDef.header, {
                    table,
                    column,
                  } as never)}
                </TableHead>
              );
            })}
          </TableRow>
        </TableHeader>
        <TableBody className="[&_tr]:hover:!bg-transparent">
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                className="hover:bg-black/[0.03] dark:hover:bg-white/[0.03]"
                data-state={row.getIsSelected() ? "selected" : undefined}
              >
                {row.getAllCells().map((cell) => {
                  const visible = cell.column.getIsVisible();

                  return (
                    <TableCell
                      key={cell.id}
                      className={cn(
                        "h-12 py-0 transition-opacity duration-150 ease-out",
                        !visible && "pointer-events-none opacity-0",
                      )}
                      aria-hidden={!visible}
                    >
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext(),
                      )}
                    </TableCell>
                  );
                })}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell
                colSpan={leafColumns.length}
                className="h-24 text-center text-muted-foreground"
              >
                {emptyMessage}
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

type DataTableColumnHeaderProps<TData, TValue> =
  HTMLAttributes<HTMLDivElement> & {
    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>;
  }

  return (
    <div className={cn("flex items-center gap-2", className)}>
      <DropdownMenu>
        <DropdownMenuTrigger
          className={cn(
            buttonVariants({ variant: "ghost", size: "sm" }),
            "h-8 transition-colors duration-200 ease-out aria-expanded:bg-accent",
          )}
        >
          <span>{title}</span>
          {column.getIsSorted() === "desc" ? (
            <ArrowDownIcon className="size-4" />
          ) : column.getIsSorted() === "asc" ? (
            <ArrowUpIcon className="size-4" />
          ) : (
            <ChevronsUpDownIcon className="size-4" />
          )}
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start">
          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
            <ArrowUpIcon className="size-4" />
            Asc
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
            <ArrowDownIcon className="size-4" />
            Desc
          </DropdownMenuItem>
          <DropdownMenuSeparator />
          <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
            <EyeOffIcon className="size-4" />
            Hide
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

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

function DataTablePagination<TData>({
  table,
  className,
}: DataTablePaginationProps<TData>) {
  return (
    <div
      className={cn(
        "flex flex-col gap-4 px-2 sm:flex-row sm:items-center sm:justify-between",
        className,
      )}
    >
      <div className="flex-1 text-sm tracking-wide text-muted-foreground">
        {table.getFilteredSelectedRowModel().rows.length} of{" "}
        {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className="flex flex-wrap items-center gap-4 lg:gap-8">
        <div className="flex items-center gap-2">
          <p className="text-sm font-medium tracking-wide">Rows per page</p>
          <NativeSelect
            size="sm"
            className="w-[70px]"
            value={`${table.getState().pagination.pageSize}`}
            onChange={(event) => {
              table.setPageSize(Number(event.target.value));
            }}
          >
            {[10, 20, 25, 30, 40, 50].map((pageSize) => (
              <NativeSelectOption key={pageSize} value={`${pageSize}`}>
                {pageSize}
              </NativeSelectOption>
            ))}
          </NativeSelect>
        </div>
        <div className="flex w-[100px] items-center justify-center text-sm font-medium tracking-wide">
          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()}
          >
            <span className="sr-only">Go to first page</span>
            <ChevronsLeftIcon className="size-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to previous page</span>
            <ChevronLeftIcon className="size-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to next page</span>
            <ChevronRightIcon className="size-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="hidden size-8 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to last page</span>
            <ChevronsRightIcon className="size-4" />
          </Button>
        </div>
      </div>
    </div>
  );
}

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

function DataTableViewOptions<TData>({
  table,
  className,
}: DataTableViewOptionsProps<TData>) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        className={cn(
          buttonVariants({ variant: "outline" }),
          "bg-muted aria-expanded:bg-accent",
          className,
        )}
      >
        <SlidersHorizontalIcon className="size-4" />
        Columns
      </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>
  );
}

function ArrowUpIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m5 12 7-7 7 7" />
      <path d="M12 19V5" />
    </svg>
  );
}

function ArrowDownIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="M12 5v14" />
      <path d="m19 12-7 7-7-7" />
    </svg>
  );
}

function ChevronsUpDownIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m7 15 5 5 5-5" />
      <path d="m7 9 5-5 5 5" />
    </svg>
  );
}

function EyeOffIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
      <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
      <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
      <path d="m2 2 20 20" />
    </svg>
  );
}

function ChevronLeftIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m15 18-6-6 6-6" />
    </svg>
  );
}

function ChevronRightIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m9 18 6-6-6-6" />
    </svg>
  );
}

function ChevronsLeftIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m11 17-5-5 5-5" />
      <path d="m18 17-5-5 5-5" />
    </svg>
  );
}

function ChevronsRightIcon({ className, ...props }: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="m6 17 5-5-5-5" />
      <path d="m13 17 5-5-5-5" />
    </svg>
  );
}

function SlidersHorizontalIcon({
  className,
  ...props
}: ComponentProps<"svg">) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
      {...props}
    >
      <path d="M10 5H3" />
      <path d="M12 19H3" />
      <path d="M14 3v4" />
      <path d="M16 17v4" />
      <path d="M21 12h-9" />
      <path d="M21 19h-5" />
      <path d="M21 5h-7" />
      <path d="M8 10v4" />
      <path d="M8 12H3" />
    </svg>
  );
}

export {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  DataTableViewOptions,
};

export type {
  DataTableProps,
  DataTableColumnHeaderProps,
  DataTablePaginationProps,
};
```
