# Data Table (/ui/components/react/data-table)



A data table isn't a single component. It's a **recipe**: you keep your own column model and feature logic in [TanStack Table](https://tanstack.com/table) — a headless, unstyled engine — and render its output with Appica UI components: [`Table`](/ui/components/react/table), [`Checkbox`](/ui/components/react/checkbox), [`Thumbnail`](/ui/components/react/thumbnail), [`Badge`](/ui/components/react/badge), [`Input`](/ui/components/react/input), [`Dropdown Menu`](/ui/components/react/dropdown-menu), and [`Scroll Area`](/ui/components/react/scroll-area).

This page is one complete, copy-pasteable example rather than a packaged component — adapt the columns and data to your own shape.

## Installation [#installation]

TanStack Table is the only extra dependency. It ships its own types, so nothing else is needed.

<InstallTabs name="@tanstack/react-table" />

## Example [#example]

A transactions table with row selection, a searchable global filter, sortable columns, a show/hide columns menu, per-row actions, and client pagination. Resize the frame down and the table scrolls horizontally instead of cramping.

```tsx
'use client'

import * as React from 'react'
import {
  type Column,
  type ColumnDef,
  type SortingState,
  type VisibilityState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@appica/ui-react/table'
import { ScrollArea } from '@appica/ui-react/scroll-area'
import { Checkbox } from '@appica/ui-react/checkbox'
import { Thumbnail } from '@appica/ui-react/thumbnail'
import { Badge } from '@appica/ui-react/badge'
import { Input } from '@appica/ui-react/input'
import { Button } from '@appica/ui-react/button'
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuGroupLabel,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuSeparator,
} from '@appica/ui-react/dropdown-menu'
import {
  Search,
  LayoutColumns,
  ChevronDown,
  ChevronUp,
  ChevronsUpDown,
  ChevronLeft,
  ChevronRight,
  DotsVertical,
  BriefcaseFilled,
  BoltFilled,
  DropletHalf2Filled,
  Eye,
  Copy,
  Pencil,
  Trash,
} from '@appica/icons-react'

type Category = 'Income' | 'Entertainment' | 'Bill' | 'Medical' | 'Food & Drinks' | 'Transport'

type Transaction = {
  id: string
  date: string
  description: string
  thumb: { type: 'image'; src: string } | { type: 'icon'; icon: React.ReactNode }
  account: string
  category: Category
  status: 'Completed' | 'Pending'
  amount: number
}

const CATEGORY_DOT: Record<Category, string> = {
  Income: 'bg-lime-500',
  Entertainment: 'bg-violet-500',
  Bill: 'bg-info-emphasis',
  Medical: 'bg-success-emphasis',
  'Food & Drinks': 'bg-pink-500',
  Transport: 'bg-warning-emphasis',
}

const COLUMN_LABELS: Record<string, string> = {
  date: 'Date',
  description: 'Description',
  account: 'Account',
  category: 'Category',
  status: 'Status',
  amount: 'Amount',
}

const TRANSACTIONS: Transaction[] = [
  {
    id: 'TXN-1042',
    date: '2026-08-30T17:23:21',
    description: 'Salary - TechNova Inc.',
    thumb: { type: 'icon', icon: <BriefcaseFilled /> },
    account: '1842',
    category: 'Income',
    status: 'Completed',
    amount: 4150,
  },
  {
    id: 'TXN-1041',
    date: '2026-08-24T07:18:53',
    description: 'Netflix Subscription',
    thumb: { type: 'image', src: '/data-table/netflix.jpg' },
    account: '1842',
    category: 'Entertainment',
    status: 'Pending',
    amount: -20,
  },
  {
    id: 'TXN-1040',
    date: '2026-08-17T09:15:00',
    description: 'Utility Bill - Electricity',
    thumb: { type: 'icon', icon: <BoltFilled /> },
    account: '1842',
    category: 'Bill',
    status: 'Pending',
    amount: -186.35,
  },
  {
    id: 'TXN-1039',
    date: '2026-08-17T08:34:46',
    description: 'Utility Bill - Water',
    thumb: { type: 'icon', icon: <DropletHalf2Filled /> },
    account: '1842',
    category: 'Bill',
    status: 'Completed',
    amount: -39.78,
  },
  {
    id: 'TXN-1038',
    date: '2026-08-13T00:27:19',
    description: 'Health Insurance',
    thumb: { type: 'image', src: '/data-table/health.jpg' },
    account: '1842',
    category: 'Medical',
    status: 'Completed',
    amount: -248,
  },
  {
    id: 'TXN-1037',
    date: '2026-08-10T02:30:52',
    description: "McDonald's",
    thumb: { type: 'image', src: '/data-table/mcdonalds.jpg' },
    account: '1842',
    category: 'Food & Drinks',
    status: 'Completed',
    amount: -32.67,
  },
  {
    id: 'TXN-1034',
    date: '2026-08-07T22:50:13',
    description: 'Uber Taxi',
    thumb: { type: 'image', src: '/data-table/uber.jpg' },
    account: '1842',
    category: 'Transport',
    status: 'Completed',
    amount: -25.4,
  },
  {
    id: 'TXN-1036',
    date: '2026-08-05T04:45:21',
    description: 'Starbucks',
    thumb: { type: 'image', src: '/data-table/starbucks.jpg' },
    account: '1842',
    category: 'Food & Drinks',
    status: 'Completed',
    amount: -7.99,
  },
  {
    id: 'TXN-1035',
    date: '2026-07-30T18:19:00',
    description: 'Salary - TechNova Inc.',
    thumb: { type: 'icon', icon: <BriefcaseFilled /> },
    account: '1842',
    category: 'Income',
    status: 'Completed',
    amount: 3980,
  },
  {
    id: 'TXN-1033',
    date: '2026-07-21T15:15:40',
    description: 'Burger King',
    thumb: { type: 'image', src: '/data-table/burger-king.jpg' },
    account: '1842',
    category: 'Food & Drinks',
    status: 'Completed',
    amount: -14.99,
  },
  {
    id: 'TXN-1032',
    date: '2026-07-19T19:36:23',
    description: 'Uklon Taxi',
    thumb: { type: 'image', src: '/data-table/uklon.jpg' },
    account: '1842',
    category: 'Transport',
    status: 'Completed',
    amount: -18.5,
  },
  {
    id: 'TXN-1031',
    date: '2026-07-12T08:05:11',
    description: 'Uber Taxi',
    thumb: { type: 'image', src: '/data-table/uber.jpg' },
    account: '1842',
    category: 'Transport',
    status: 'Completed',
    amount: -31.2,
  },
  {
    id: 'TXN-1030',
    date: '2026-07-08T13:22:47',
    description: 'Starbucks',
    thumb: { type: 'image', src: '/data-table/starbucks.jpg' },
    account: '1842',
    category: 'Food & Drinks',
    status: 'Pending',
    amount: -6.45,
  },
  {
    id: 'TXN-1029',
    date: '2026-06-30T17:00:00',
    description: 'Salary - TechNova Inc.',
    thumb: { type: 'icon', icon: <BriefcaseFilled /> },
    account: '1842',
    category: 'Income',
    status: 'Completed',
    amount: 4150,
  },
]

function formatDate(iso: string) {
  const date = new Date(iso)
  const datePart = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
  const timePart = new Intl.DateTimeFormat('en-US', {
    hour: 'numeric',
    minute: '2-digit',
    hour12: true,
  }).format(date)
  return `${datePart} ${timePart}`
}

function formatAmount(amount: number) {
  const value = Math.abs(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
  return `${amount > 0 ? '+' : '-'} $${value}`
}

function TransactionThumb({ transaction }: { transaction: Transaction }) {
  if (transaction.thumb.type === 'image') {
    return <Thumbnail size="sm" shape="circle" src={transaction.thumb.src} alt={transaction.description} />
  }
  return (
    <Thumbnail size="sm" shape="circle" variant="icon-soft">
      {transaction.thumb.icon}
    </Thumbnail>
  )
}

function SortableHeader({ column, children }: { column: Column<Transaction>; children: React.ReactNode }) {
  const sorted = column.getIsSorted()
  return (
    <button
      type="button"
      onClick={() => column.toggleSorting(sorted === 'asc')}
      className="hover:text-foreground-intense focus-visible:outline-ring rounded-3xs -mx-1 inline-flex cursor-pointer items-center gap-1 px-1 outline-offset-2 focus-visible:outline-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.85]"
    >
      {children}
      {sorted === 'asc' ? (
        <ChevronUp />
      ) : sorted === 'desc' ? (
        <ChevronDown />
      ) : (
        <ChevronsUpDown className="text-foreground-muted" />
      )}
    </button>
  )
}

function RowActions() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <Button variant="ghost" size="icon-sm" aria-label="Open actions menu">
            <DotsVertical />
          </Button>
        }
      />
      <DropdownMenuContent align="end">
        <DropdownMenuGroup>
          <DropdownMenuItem>
            <Eye data-icon="start" />
            View details
          </DropdownMenuItem>
          <DropdownMenuItem>
            <Copy data-icon="start" />
            Copy transaction ID
          </DropdownMenuItem>
          <DropdownMenuItem>
            <Pencil data-icon="start" />
            Edit
          </DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem className="text-error-emphasis! data-highlighted:before:bg-error-subtle!">
          <Trash data-icon="start" />
          Delete
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

const columns: ColumnDef<Transaction>[] = [
  {
    id: 'select',
    header: ({ table }) => (
      <Checkbox
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
        aria-label="Select all rows"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(checked) => row.toggleSelected(checked === true)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: 'date',
    header: ({ column }) => <SortableHeader column={column}>Date</SortableHeader>,
    cell: ({ row }) => formatDate(row.original.date),
  },
  {
    accessorKey: 'description',
    header: ({ column }) => <SortableHeader column={column}>Description</SortableHeader>,
    cell: ({ row }) => (
      <div className="flex items-center gap-3">
        <TransactionThumb transaction={row.original} />
        <span className="text-foreground-intense font-medium">{row.original.description}</span>
      </div>
    ),
  },
  {
    accessorKey: 'account',
    header: ({ column }) => <SortableHeader column={column}>Account</SortableHeader>,
    cell: ({ row }) => (
      <span>
        Checking <span className="text-foreground-muted">****{row.original.account}</span>
      </span>
    ),
  },
  {
    accessorKey: 'category',
    header: ({ column }) => <SortableHeader column={column}>Category</SortableHeader>,
    cell: ({ row }) => (
      <Badge variant="outline" className="gap-1.25">
        <span className={`size-2 shrink-0 rounded-full ${CATEGORY_DOT[row.original.category]}`} />
        {row.original.category}
      </Badge>
    ),
  },
  {
    accessorKey: 'status',
    header: ({ column }) => <SortableHeader column={column}>Status</SortableHeader>,
    cell: ({ row }) => row.original.status,
  },
  {
    accessorKey: 'amount',
    header: ({ column }) => <SortableHeader column={column}>Amount</SortableHeader>,
    cell: ({ row }) => (
      <span
        className={
          row.original.status === 'Pending'
            ? 'text-foreground-muted font-semibold'
            : 'text-foreground-intense font-semibold'
        }
      >
        {formatAmount(row.original.amount)}
      </span>
    ),
  },
  {
    id: 'actions',
    cell: () => <RowActions />,
    enableSorting: false,
    enableHiding: false,
  },
]

export default function DataTableExample() {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
  const [rowSelection, setRowSelection] = React.useState({})
  const [globalFilter, setGlobalFilter] = React.useState('')

  const table = useReactTable({
    data: TRANSACTIONS,
    columns,
    state: { sorting, columnVisibility, rowSelection, globalFilter },
    onSortingChange: setSorting,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    onGlobalFilterChange: setGlobalFilter,
    globalFilterFn: 'includesString',
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 8 } },
  })

  const rows = table.getRowModel().rows

  return (
    <div className="flex w-full flex-col gap-4">
      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
        <Input
          placeholder="Search transactions..."
          value={globalFilter}
          onChange={(event) => setGlobalFilter(event.target.value)}
          startSlot={<Search className="text-foreground-muted size-4" />}
          clearable
          onClear={() => setGlobalFilter('')}
          className="sm:max-w-2xs"
        />
        <DropdownMenu>
          <DropdownMenuTrigger
            className="group/columns"
            render={
              <Button variant="outline">
                <LayoutColumns data-icon="start" />
                Columns
                <ChevronDown
                  data-icon="end"
                  className="transition-transform duration-200 ease-out group-data-popup-open/columns:rotate-180 motion-reduce:transition-none"
                />
              </Button>
            }
          />
          <DropdownMenuContent align="end">
            <DropdownMenuGroup>
              <DropdownMenuGroupLabel>Toggle columns</DropdownMenuGroupLabel>
              {table
                .getAllColumns()
                .filter((column) => column.getCanHide())
                .map((column) => (
                  <DropdownMenuCheckboxItem
                    key={column.id}
                    checked={column.getIsVisible()}
                    onCheckedChange={(value) => column.toggleVisibility(value === true)}
                  >
                    {COLUMN_LABELS[column.id] ?? column.id}
                  </DropdownMenuCheckboxItem>
                ))}
            </DropdownMenuGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      <ScrollArea
        orientation="horizontal"
        scrollbarVisibility="auto"
        className="[&_td]:whitespace-nowrap [&_th]:whitespace-nowrap"
      >
        <Table hoverableRows>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id} className={header.column.id === 'amount' ? 'text-end' : undefined}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {rows.length ? (
              rows.map((row) => (
                <TableRow key={row.id} highlighted={row.getIsSelected()}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id} className={cell.column.id === 'amount' ? 'text-end' : undefined}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="text-foreground-muted h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </ScrollArea>

      <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
        <p className="text-foreground-muted text-sm">
          {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
          selected.
        </p>
        <div className="flex items-center gap-2">
          <span className="text-foreground-muted text-sm">
            Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
          </span>
          <Button
            variant="outline"
            size="sm"
            disabled={!table.getCanPreviousPage()}
            onClick={() => table.previousPage()}
          >
            <ChevronLeft data-icon="start" className="-ms-1" />
            Previous
          </Button>
          <Button variant="outline" size="sm" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
            Next
            <ChevronRight data-icon="end" className="-me-1" />
          </Button>
        </div>
      </div>
    </div>
  )
}
```

## How it works [#how-it-works]

TanStack Table owns the state and the row model; Appica UI owns the rendering. The bridge is `flexRender`, which turns each column's `header`/`cell` definition into React nodes that you drop straight into `Table` parts:

```tsx
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@appica/ui-react/table'
import { flexRender } from '@tanstack/react-table'
```

```tsx
<Table hoverableRows>
  <TableHeader>
    {table.getHeaderGroups().map((headerGroup) => (
      <TableRow key={headerGroup.id}>
        {headerGroup.headers.map((header) => (
          <TableHead key={header.id}>
            {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
          </TableHead>
        ))}
      </TableRow>
    ))}
  </TableHeader>
  <TableBody>
    {table.getRowModel().rows.map((row) => (
      <TableRow key={row.id} highlighted={row.getIsSelected()}>
        {row.getVisibleCells().map((cell) => (
          <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
        ))}
      </TableRow>
    ))}
  </TableBody>
</Table>
```

Each feature is a state hook plus the matching row-model helper passed to `useReactTable`:

* **Row selection** — a `select` column whose `header` and `cell` render a [`Checkbox`](/ui/components/react/checkbox). The header checkbox wires `checked` to `getIsAllPageRowsSelected()` and `indeterminate` to `getIsSomePageRowsSelected()` for the mixed-state dash; mark the row `highlighted` when `row.getIsSelected()` to tint it.
* **Sorting** — `getSortedRowModel()` plus a `SortableHeader` button that calls `column.toggleSorting()` and swaps a `ChevronsUpDown` / `ChevronUp` / `ChevronDown` icon off `column.getIsSorted()`.
* **Global filter** — `getFilteredRowModel()` with `globalFilterFn: 'includesString'`, driven by an [`Input`](/ui/components/react/input) whose `startSlot` holds a search icon.
* **Column visibility** — a [`Dropdown Menu`](/ui/components/react/dropdown-menu) of `DropdownMenuCheckboxItem`s, one per `table.getAllColumns().filter((c) => c.getCanHide())`, each toggling `column.toggleVisibility()`.
* **Pagination** — `getPaginationRowModel()` and an `initialState.pagination.pageSize`, with `Previous` / `Next` [`Button`](/ui/components/react/button)s gated on `getCanPreviousPage()` / `getCanNextPage()`.
* **Per-row actions** — a [`Dropdown Menu`](/ui/components/react/dropdown-menu) in a trailing `actions` column. Mark it `enableSorting: false` and `enableHiding: false` so it stays put.

### Rich cells [#rich-cells]

Because a `cell` returns arbitrary JSX, you compose the same components you'd use anywhere. The description cell pairs a [`Thumbnail`](/ui/components/react/thumbnail) (an `icon-soft` variant for category icons, an image for brand logos) with an emphasized label, and the category cell renders an outline [`Badge`](/ui/components/react/badge) with a small color dot — keeping the badge neutral while the dot carries the category color:

```tsx
const CATEGORY_DOT = {
  Income: 'bg-lime-500',
  Bill: 'bg-info-emphasis',
  Medical: 'bg-success-emphasis',
  Transport: 'bg-warning-emphasis',
}
```

```tsx
<Badge variant="outline">
  <span className={`size-2 shrink-0 rounded-full ${CATEGORY_DOT[category]}`} />
  {category}
</Badge>
```

### Horizontal scroll [#horizontal-scroll]

Wrap the table in a horizontal [`Scroll Area`](/ui/components/react/scroll-area) and keep cells from wrapping with `whitespace-nowrap`, so a wide table scrolls sideways on small screens instead of squeezing its columns:

```tsx
<ScrollArea orientation="horizontal" className="[&_td]:whitespace-nowrap [&_th]:whitespace-nowrap">
  <Table hoverableRows>{/* … */}</Table>
</ScrollArea>
```

## Accessibility [#accessibility]

* The markup is a real `<table>` — `flexRender` only fills the cells, so screen readers still announce rows, columns, and headers natively. See the [`Table`](/ui/components/react/table#accessibility) notes.
* Give every selection `Checkbox` an `aria-label` (`"Select all rows"`, `"Select row"`) — the checkbox has no visible text of its own.
* Sort controls are real `<button>`s inside the `<th>`, so they're keyboard-focusable and operable with Enter/Space.
* The per-row actions trigger needs an `aria-label` (e.g. `"Open actions menu"`) since it's an icon-only button.
* Row `highlighted` is a background cue only; selection state is conveyed by the checkbox, not by color alone.
