Skip to content
  1. Tables
  2. Data table

Data table

A table wired to TanStack Table: sorting, pagination, row selection and reorderable columns.

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026

Showing 1 to 5 of 12 payments

'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let { table } = useDataTable<Payment>({
    data: payments,
    columns,
    getRowId: (row) => row.id,
    defaultSorting: [{ id: 'createdAt', desc: true }],
    pageSize: 5,
  })

  return <DataTable table={table} aria-label="Payments" />
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table } = useDataTable<Payment>({
  data: payments,
  columns,
  getRowId: (row) => row.id,
  defaultSorting: [{ id: 'createdAt', desc: true }],
  pageSize: 5,
})
</script>

<template>
  <DataTable :table="table" aria-label="Payments" />
</template>

Dependencies

The table is driven by TanStack Table, and the column picker reorders its list with dnd-kit. Install both alongside the base dependencies:

npm install @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
pnpm add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
yarn add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
bun add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install @tanstack/vue-table vuedraggable
pnpm add @tanstack/vue-table vuedraggable
yarn add @tanstack/vue-table vuedraggable
bun add @tanstack/vue-table vuedraggable

Only data-table-columns.tsx needs dnd-kit. Skip it if you copy the table without the column picker.

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from '@heroicons/react/16/solid'
import { flexRender, type Row, type Table as TableInstance } from '@tanstack/react-table'
import type React from 'react'
import { Fragment } from 'react'
import clsx from 'clsx'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table'

const edgePadding = 'sm:first:pl-3! sm:last:pr-3!'

type RowLink<T> =
  | { rowHref: (row: T) => string; rowTitle: (row: T) => string }
  | { rowHref?: never; rowTitle?: never }

export function DataTable<T>({
  table,
  rowHref,
  rowTitle,
  renderExpanded,
  className,
  'aria-label': label,
}: {
  table: TableInstance<T>
  renderExpanded?: (row: T) => React.ReactNode
  className?: string
  'aria-label'?: string
} & RowLink<T>) {
  let headers = table.getHeaderGroups()[0]?.headers ?? []

  return (
    <Table aria-label={label} className={clsx(className, '[--gutter:--spacing(4)] lg:[--gutter:--spacing(8)]')}>
      <TableHead>
        <TableRow>
          {headers.map((header) => (
            <TableHeader
              key={header.id}
              aria-sort={
                header.column.getIsSorted() === 'asc'
                  ? 'ascending'
                  : header.column.getIsSorted() === 'desc'
                    ? 'descending'
                    : header.column.getCanSort()
                      ? 'none'
                      : undefined
              }
              className={clsx(edgePadding, header.column.columnDef.meta?.cellClass)}
            >
              {header.column.getCanSort() ? (
                <button
                  type="button"
                  onClick={() => header.column.toggleSorting()}
                  className="group -mx-1 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent-500 dark:hover:text-white"
                >
                  {flexRender(header.column.columnDef.header, header.getContext())}
                  {header.column.getIsSorted() === 'asc' ? (
                    <ChevronUpIcon className="size-4 shrink-0 fill-zinc-950 dark:fill-white" />
                  ) : header.column.getIsSorted() === 'desc' ? (
                    <ChevronDownIcon className="size-4 shrink-0 fill-zinc-950 dark:fill-white" />
                  ) : (
                    <ChevronUpDownIcon className="size-4 shrink-0 fill-zinc-400 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 dark:fill-zinc-500" />
                  )}
                </button>
              ) : (
                flexRender(header.column.columnDef.header, header.getContext())
              )}
            </TableHeader>
          ))}
        </TableRow>
      </TableHead>
      <TableBody>
        {table.getRowModel().rows.map((row) => (
          <Fragment key={row.id}>
            <TableRow
              href={rowHref?.(row.original)}
              title={rowTitle?.(row.original)}
              data-selected={row.getIsSelected() ? '' : undefined}
              className="data-selected:border-l-2 data-selected:border-l-zinc-950 data-selected:bg-zinc-950/2.5 data-selected:hover:bg-zinc-950/5 dark:data-selected:border-l-white dark:data-selected:bg-white/5 dark:data-selected:hover:bg-white/10"
            >
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id} className={clsx(edgePadding, cell.column.columnDef.meta?.cellClass)}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
            {renderExpanded && row.getIsExpanded() && (
              <TableRow>
                <TableCell colSpan={row.getVisibleCells().length} className={clsx(edgePadding, 'bg-zinc-950/2.5 dark:bg-white/2.5')}>
                  {renderExpanded(row.original)}
                </TableCell>
              </TableRow>
            )}
          </Fragment>
        ))}
      </TableBody>
    </Table>
  )
}

export function DataTableExpandButton<T>({ row, label = 'Toggle details' }: { row: Row<T>; label?: string }) {
  return (
    <button
      type="button"
      aria-expanded={row.getIsExpanded()}
      aria-label={label}
      onClick={row.getToggleExpandedHandler()}
      className="relative cursor-pointer rounded-lg p-1 text-zinc-500 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:text-zinc-400 dark:hover:text-white"
    >
      <span className="absolute top-1/2 left-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" aria-hidden="true" />
      <ChevronDownIcon className={clsx('size-4 shrink-0 transition-transform', row.getIsExpanded() && 'rotate-180')} />
    </button>
  )
}
<script setup lang="ts" generic="T">
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from '@heroicons/vue/16/solid'
import { FlexRender, type Table as TableInstance } from '@tanstack/vue-table'
import { computed } from 'vue'
import TableBody from './table-body.vue'
import TableCell from './table-cell.vue'
import TableHead from './table-head.vue'
import TableHeader from './table-header.vue'
import TableRow from './table-row.vue'
import Table from './table.vue'

type RowLink =
  | { rowHref: (row: T) => string; rowTitle: (row: T) => string }
  | { rowHref?: never; rowTitle?: never }

const props = defineProps<{ table: TableInstance<T>; ariaLabel?: string } & RowLink>()

const headers = computed(() => props.table.getHeaderGroups()[0]?.headers ?? [])
const rows = computed(() => props.table.getRowModel().rows)

const edgePadding = 'sm:first:pl-3! sm:last:pr-3!'
</script>

<template>
  <Table :aria-label="ariaLabel" class="[--gutter:--spacing(4)] lg:[--gutter:--spacing(8)]">
    <TableHead>
      <TableRow>
        <TableHeader
          v-for="header in headers"
          :key="header.id"
          :aria-sort="
            header.column.getIsSorted() === 'asc'
              ? 'ascending'
              : header.column.getIsSorted() === 'desc'
                ? 'descending'
                : header.column.getCanSort()
                  ? 'none'
                  : undefined
          "
          :class="[edgePadding, header.column.columnDef.meta?.cellClass]"
        >
          <slot :name="`head-${header.column.id}`" :header="header">
            <button
              v-if="header.column.getCanSort()"
              type="button"
              class="group -mx-1 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent-500 dark:hover:text-white"
              @click="header.column.toggleSorting()"
            >
              <FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
              <ChevronUpIcon
                v-if="header.column.getIsSorted() === 'asc'"
                class="size-4 shrink-0 fill-zinc-950 dark:fill-white"
              />
              <ChevronDownIcon
                v-else-if="header.column.getIsSorted() === 'desc'"
                class="size-4 shrink-0 fill-zinc-950 dark:fill-white"
              />
              <ChevronUpDownIcon
                v-else
                class="size-4 shrink-0 fill-zinc-400 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 dark:fill-zinc-500"
              />
            </button>
            <FlexRender v-else :render="header.column.columnDef.header" :props="header.getContext()" />
          </slot>
        </TableHeader>
      </TableRow>
    </TableHead>
    <TableBody>
      <template v-for="row in rows" :key="row.id">
        <TableRow
          :href="rowHref?.(row.original)"
          :title="rowTitle?.(row.original)"
          :data-selected="row.getIsSelected() ? '' : undefined"
          class="data-selected:border-l-2 data-selected:border-l-zinc-950 data-selected:bg-zinc-950/2.5 data-selected:hover:bg-zinc-950/5 dark:data-selected:border-l-white dark:data-selected:bg-white/5 dark:data-selected:hover:bg-white/10"
        >
          <TableCell
            v-for="cell in row.getVisibleCells()"
            :key="cell.id"
            :class="[edgePadding, cell.column.columnDef.meta?.cellClass]"
          >
            <slot :name="`cell-${cell.column.id}`" :row="cell.row.original" :cell="cell">
              <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
            </slot>
          </TableCell>
        </TableRow>
        <TableRow v-if="$slots.expanded && row.getIsExpanded()">
          <TableCell :colspan="row.getVisibleCells().length" :class="[edgePadding, 'bg-zinc-950/2.5 dark:bg-white/2.5']">
            <slot name="expanded" :row="row.original" />
          </TableCell>
        </TableRow>
      </template>
    </TableBody>
  </Table>
</template>

Component API

PropDefaultDescription
DataTablerenders a <Table> element
renderExpanded-Renders the detail row under an expanded row: `(row) => node`. Needs `expandable` on the hook.
table-The table instance returned by `useDataTable`.
aria-label-Accessible name of the table, also given to its scroll region when it overflows.
rowHref-Turns every row into a link: `(row) => string`. Goes hand in hand with `rowTitle`.
rowTitle-Accessible name of that link: `(row) => string`. The type requires it as soon as `rowHref` is set.
DataTableColumnsrenders a <Popover> element
table-The table instance.
customized-Shows the reset row when the columns have been changed.
onReset-Called when that reset row is pressed.
DataTableExpandButtonrenders a <button> element
row-The TanStack row, from the cell context.
label'Toggle details'Accessible name of the chevron.
DataTablePaginationrenders a <div> element
table-The table instance.
noun'results'What the rows are called: `payments`, `customers`…
DataTableBulkBarrenders a <div> element
count-Number of selected rows. The bar hides itself at zero and announces the count. It floats over the bottom of the page, so leave room under the table while it shows.
max-Cap on a bulk action. Over it, the bar says so.
onClear-Called by the dismiss button.
PropDefaultDescription
DataTablebuilt on the <Table> component
#expanded-Scoped slot `{ row }` rendering the detail row under an expanded row. Needs `expandable` on the composable.
table-The table instance returned by `useDataTable`.
aria-label-Accessible name of the table, also given to its scroll region when it overflows.
rowHref-Turns every row into a link: `(row) => string`. Goes hand in hand with `rowTitle`.
rowTitle-Accessible name of that link: `(row) => string`. The type requires it as soon as `rowHref` is set.
DataTableColumnsbuilt on the Reka UI <PopoverRoot> component
table-The table instance.
customized-Shows the reset row when the columns have been changed.
@reset-Emitted when that reset row is pressed.
DataTableExpandButtonrenders a <button> element
row-The TanStack row, from the cell context.
label'Toggle details'Accessible name of the chevron.
DataTablePaginationrenders a <div> element
table-The table instance.
noun'results'What the rows are called: `payments`, `customers`…
DataTableBulkBarrenders a <div> element
count-Number of selected rows. The bar hides itself at zero and announces the count. It floats over the bottom of the page, so leave room under the table while it shows.
max-Cap on a bulk action. Over it, the bar says so.
@clear-Emitted by the dismiss button.

useDataTable

The hook owns the state (sorting, pagination, selection, column visibility and order) and hands back the table instance every component here expects.

PropDefaultDescription
useDataTable(options)
expandablefalseLets rows open a detail row under them.
data-The rows, already filtered. When they change, a page past the end falls back to the last one and selected rows that are gone are dropped.
columns-Array of TanStack `ColumnDef`.
getRowId-Stable id of a row, used for selection.
pageSize10Rows per page.
defaultSorting[]Sorting applied on first render.
defaultVisibility{}Columns hidden until the picker is touched.
manualfalseSorting and pagination handled by the server.
rowCount-Total number of rows on the server, in manual mode. Drives the page count.
preferences-A `[value, setValue]` pair holding the column preferences, for localStorage or a cookie. In memory by default.
PropDefaultDescription
useDataTable(options)
expandablefalseLets rows open a detail row under them.
data-The rows, already filtered. Ref, getter or plain array. When they change, a page past the end falls back to the last one and selected rows that are gone are dropped.
columns-Array of TanStack `ColumnDef`. Ref or getter to make it reactive.
getRowId-Stable id of a row, used for selection.
pageSize10Rows per page.
defaultSorting[]Sorting applied on first render.
defaultVisibility{}Columns hidden until the picker is touched.
manualfalseSorting and pagination handled by the server.
rowCount-Total number of rows on the server, in manual mode. Drives the page count. Ref, getter or number.
preferences-Ref holding the column preferences. In memory by default.

It returns:

  • table: the TanStack instance, passed to every component on this page.
  • sorting and pagination: the current state, to forward to a server in manual mode.
  • selectedRows: the selected rows themselves, not their ids.
  • isCustomized and resetColumns: for the column picker.
  • clearSelection: for the bulk bar.

The table goes back to the first page whenever the sorting changes. When the rows change, a page past the end falls back to the last one, and selected rows that are no longer there are dropped.

Examples

Basic example

Columns are plain TanStack ColumnDef. meta.label names the column in the picker, meta.cellClass aligns or sizes it, and sortable headers get a sort button on hover:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026

Showing 1 to 5 of 12 payments

'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let { table } = useDataTable<Payment>({
    data: payments,
    columns,
    getRowId: (row) => row.id,
    defaultSorting: [{ id: 'createdAt', desc: true }],
    pageSize: 5,
  })

  return <DataTable table={table} aria-label="Payments" />
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table } = useDataTable<Payment>({
  data: payments,
  columns,
  getRowId: (row) => row.id,
  defaultSorting: [{ id: 'createdAt', desc: true }],
  pageSize: 5,
})
</script>

<template>
  <DataTable :table="table" aria-label="Payments" />
</template>

Custom cells

A cell is a render function on the column, so it can hold whatever markup the row needs:

Method

Mariam Keita

mariam@example.com

$80.00

succeededVisa •••• 4242Jun 2, 2026

Chidi Okeke

chidi@example.com

$142.50

succeededMastercard •••• 5100May 28, 2026

Lina Mansouri

lina@example.com

$32.00

pendingOrange MoneyMay 19, 2026

Serge Ateba

serge@example.com

$276.00

refundedVisa •••• 1881May 9, 2026

Salma Bennani

salma@example.com

$54.00

failedWaveApr 30, 2026
'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const detailedColumns: ColumnDef<Payment, unknown>[] = columns.map((column) =>
  column.id === 'customer'
    ? {
        ...column,
        cell: ({ row }) => (
          <div className="min-w-0">
            <p className="truncate font-medium text-zinc-950 dark:text-white">{row.original.customer}</p>
            <p className="mt-0.5 truncate text-zinc-500 dark:text-zinc-400">{row.original.email}</p>
          </div>
        ),
      }
    : column
)

export default function Example() {
  let { table } = useDataTable<Payment>({ data: payments, columns: detailedColumns, getRowId: (row) => row.id })

  return <DataTable table={table} aria-label="Payments" />
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table } = useDataTable<Payment>({ data: payments, columns, getRowId: (row) => row.id })
</script>

<template>
  <DataTable :table="table" aria-label="Payments">
    <template #cell-customer="{ row }">
      <div class="min-w-0">
        <p class="truncate font-medium text-zinc-950 dark:text-white">{{ row.customer }}</p>
        <p class="mt-0.5 truncate text-zinc-500 dark:text-zinc-400">{{ row.email }}</p>
      </div>
    </template>
  </DataTable>
</template>

rowHref turns each row into a link: the whole row, not just one cell, so the pointer target matches what the eye sees:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026
'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let { table } = useDataTable<Payment>({ data: payments, columns, getRowId: (row) => row.id })

  return (
    <DataTable
      table={table}
      aria-label="Payments"
      rowHref={(row) => `/payments/${row.id}`}
      rowTitle={(row) => `Payment ${row.id}`}
    />
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table } = useDataTable<Payment>({ data: payments, columns, getRowId: (row) => row.id })
</script>

<template>
  <DataTable
    :table="table"
    aria-label="Payments"
    :row-href="(row) => `/payments/${row.id}`"
    :row-title="(row) => `Payment ${row.id}`"
  />
</template>

The link covering the row takes its accessible name from rowTitle, so pass it whenever you pass rowHref. A screen reader would otherwise announce an unnamed link per row.

Selection and bulk actions

Add a select column marked fixed and render a checkbox in its header and its cells. DataTableBulkBar then floats over the page as soon as something is selected. Leave room under the table while it shows, so it never covers the last rows:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026

Showing 1 to 5 of 12 payments

'use client'

import { Badge } from '@/components/badge'
import { Button } from '@/components/button'
import { Checkbox } from '@/components/checkbox'
import { DataTableBulkBar } from '@/components/data-table-bulk-bar'
import { DataTable } from '@/components/data-table'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { useState } from 'react'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const selectionColumns: ColumnDef<Payment, unknown>[] = [
  {
    id: 'select',
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all on this page"
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onChange={(checked) => table.toggleAllPageRowsSelected(checked)}
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label={`Select ${row.original.id}`}
        checked={row.getIsSelected()}
        onChange={(checked) => row.toggleSelected(checked)}
      />
    ),
    enableSorting: false,
    enableHiding: false,
    meta: { label: 'Selection', fixed: true, cellClass: 'w-0 pr-0' },
  },
  ...columns,
]

export default function Example() {
  let [rows, setRows] = useState(payments)
  let { table, selectedRows, clearSelection } = useDataTable<Payment>({
    data: rows,
    columns: selectionColumns,
    getRowId: (row) => row.id,
  })

  function refund() {
    let ids = new Set(selectedRows.map((row) => row.id))
    setRows((current) => current.map((row) => (ids.has(row.id) ? { ...row, status: 'refunded' as const } : row)))
    clearSelection()
  }

  function remove() {
    let ids = new Set(selectedRows.map((row) => row.id))
    setRows((current) => current.filter((row) => !ids.has(row.id)))
  }

  return (
    <div className={selectedRows.length > 0 ? 'pb-24' : undefined}>
      <DataTable table={table} aria-label="Payments" />
      <DataTableBulkBar count={selectedRows.length} max={100} onClear={clearSelection}>
        <Button outline onClick={refund}>
          Refund
        </Button>
        <Button color="red" onClick={remove}>
          Delete
        </Button>
      </DataTableBulkBar>
    </div>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import Button from '@/components/button.vue'
import Checkbox from '@/components/checkbox.vue'
import DataTableBulkBar from '@/components/data-table-bulk-bar.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h, ref } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const selectionColumns: ColumnDef<Payment, unknown>[] = [
  {
    id: 'select',
    header: '',
    enableSorting: false,
    enableHiding: false,
    meta: { label: 'Selection', fixed: true, cellClass: 'w-0 pr-0' },
  },
  ...columns,
]

const rows = ref(payments)

const { table, selectedRows, clearSelection } = useDataTable<Payment>({
  data: rows,
  columns: selectionColumns,
  getRowId: (row) => row.id,
})

function refund() {
  const ids = new Set(selectedRows.value.map((row) => row.id))
  rows.value = rows.value.map((row) => (ids.has(row.id) ? { ...row, status: 'refunded' as const } : row))
  clearSelection()
}

function remove() {
  const ids = new Set(selectedRows.value.map((row) => row.id))
  rows.value = rows.value.filter((row) => !ids.has(row.id))
}
</script>

<template>
  <div :class="selectedRows.length > 0 ? 'pb-24' : undefined">
    <DataTable :table="table" aria-label="Payments">
      <template #head-select>
        <Checkbox
          aria-label="Select all on this page"
          :model-value="table.getIsAllPageRowsSelected()"
          :indeterminate="table.getIsSomePageRowsSelected()"
          @update:model-value="(checked) => table.toggleAllPageRowsSelected(checked)"
        />
      </template>

      <template #cell-select="{ cell }">
        <Checkbox
          :aria-label="`Select ${cell.row.original.id}`"
          :model-value="cell.row.getIsSelected()"
          @update:model-value="(checked) => cell.row.toggleSelected(checked)"
        />
      </template>
    </DataTable>

    <DataTableBulkBar :count="selectedRows.length" :max="100" @clear="clearSelection">
      <Button outline @click="refund">Refund</Button>
      <Button color="red" @click="remove">Delete</Button>
    </DataTableBulkBar>
  </div>
</template>

Choosing columns

DataTableColumns hides, shows and reorders the data columns. Columns marked fixed stay out of the list and keep their position, so a row menu can never end up in the middle of the table:

Mariam Keita

$80.00

succeededJun 2, 2026
Chidi Okeke

$142.50

succeededMay 28, 2026
Lina Mansouri

$32.00

pendingMay 19, 2026
Serge Ateba

$276.00

refundedMay 9, 2026
Salma Bennani

$54.00

failedApr 30, 2026
'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { DataTableColumns } from '@/components/data-table-columns'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let { table, isCustomized, resetColumns } = useDataTable<Payment>({
    data: payments,
    columns,
    getRowId: (row) => row.id,
    defaultVisibility: { method: false },
  })

  return (
    <>
      <div className="flex justify-end">
        <DataTableColumns table={table} customized={isCustomized} onReset={resetColumns} />
      </div>
      <DataTable table={table} aria-label="Payments" className="mt-4" />
    </>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTableColumns from '@/components/data-table-columns.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table, isCustomized, resetColumns } = useDataTable<Payment>({
  data: payments,
  columns,
  getRowId: (row) => row.id,
  defaultVisibility: { method: false },
})
</script>

<template>
  <div class="flex justify-end">
    <DataTableColumns :table="table" :customized="isCustomized" @reset="resetColumns" />
  </div>
  <DataTable :table="table" aria-label="Payments" class="mt-4" />
</template>

Persisting the columns

Column preferences live in memory by default, so they reset on reload. Hand useDataTable your own state to keep them: any value and setter will do, backed by localStorage or a cookie. With Nuxt, a cookie is read during server rendering, so the columns do not jump after hydration:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026
'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { DataTableColumns } from '@/components/data-table-columns'
import { useDataTable, type ColumnPreferences } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { useEffect, useState } from 'react'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const storageKey = 'table-payments'

function useStoredPreferences() {
  let [value, setValue] = useState<ColumnPreferences | null>(null)

  useEffect(() => {
    try {
      let saved = localStorage.getItem(storageKey)
      if (saved) setValue(JSON.parse(saved))
    } catch {}
  }, [])

  function save(next: ColumnPreferences | null) {
    setValue(next)
    try {
      if (next) localStorage.setItem(storageKey, JSON.stringify(next))
      else localStorage.removeItem(storageKey)
    } catch {}
  }

  return [value, save] as [ColumnPreferences | null, (next: ColumnPreferences | null) => void]
}

export default function Example() {
  let preferences = useStoredPreferences()
  let { table, isCustomized, resetColumns } = useDataTable<Payment>({
    data: payments,
    columns,
    getRowId: (row) => row.id,
    preferences,
  })

  return (
    <>
      <div className="flex justify-end">
        <DataTableColumns table={table} customized={isCustomized} onReset={resetColumns} />
      </div>
      <DataTable table={table} aria-label="Payments" className="mt-4" />
    </>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTableColumns from '@/components/data-table-columns.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable, type ColumnPreferences } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const preferences = useCookie<ColumnPreferences | null>('table-payments', { default: () => null })

const { table, isCustomized, resetColumns } = useDataTable<Payment>({
  data: payments,
  columns,
  getRowId: (row) => row.id,
  preferences,
})
</script>

<template>
  <div class="flex justify-end">
    <DataTableColumns :table="table" :customized="isCustomized" @reset="resetColumns" />
  </div>
  <DataTable :table="table" aria-label="Payments" class="mt-4" />
</template>

Pagination

DataTablePagination counts the rows before paging, so it reports the size of the filtered set rather than of the current page. From two pages it adds the page numbers: up to nine in full, then a window of five around the current one that keeps its width at both ends. Under sm the numbers give way to a Page 3 of 14:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
Salma Bennani

$54.00

failedWaveApr 30, 2026

Showing 1 to 5 of 48 payments

'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table-pagination'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let { table } = useDataTable<Payment>({ data: payments, columns, getRowId: (row) => row.id, pageSize: 2 })

  return (
    <>
      <DataTable table={table} aria-label="Payments" />
      <DataTablePagination table={table} noun="payments" className="mt-6" />
    </>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTablePagination from '@/components/data-table-pagination.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const { table } = useDataTable<Payment>({ data: payments, columns, getRowId: (row) => row.id, pageSize: 2 })
</script>

<template>
  <DataTable :table="table" aria-label="Payments" />
  <DataTablePagination :table="table" noun="payments" class="mt-6" />
</template>

Expandable rows

With expandable on the hook, a row can open a detail row under it. Put a DataTableExpandButton in a fixed zero-width column, and return the detail from renderExpanded:

Method
Mariam Keita

$80.00

succeededVisa •••• 4242Jun 2, 2026
Chidi Okeke

$142.50

succeededMastercard •••• 5100May 28, 2026
Lina Mansouri

$32.00

pendingOrange MoneyMay 19, 2026
Serge Ateba

$276.00

refundedVisa •••• 1881May 9, 2026
'use client'

import { Badge } from '@/components/badge'
import { DataTable, DataTableExpandButton } from '@/components/data-table'
import { FactList } from '@/components/fact-list'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const expandableColumns: ColumnDef<Payment, unknown>[] = [
  {
    id: 'expand',
    header: '',
    enableSorting: false,
    enableHiding: false,
    cell: ({ row }) => <DataTableExpandButton row={row} />,
    meta: { label: 'Details', fixed: true, cellClass: 'w-0 pr-0' },
  },
  ...columns,
]

export default function Example() {
  let { table } = useDataTable<Payment>({ data: payments, columns: expandableColumns, getRowId: (row) => row.id, expandable: true })

  return (
    <DataTable
      table={table}
      aria-label="Payments"
      renderExpanded={(payment) => (
        <FactList
          layout="rows"
          facts={[
            { label: 'Method', value: payment.method },
            { label: 'Email', value: payment.email },
          ]}
        />
      )}
    />
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTableExpandButton from '@/components/data-table-expand-button.vue'
import DataTable from '@/components/data-table.vue'
import FactList from '@/components/fact-list.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const expandableColumns: ColumnDef<Payment, unknown>[] = [
  {
    id: 'expand',
    header: '',
    enableSorting: false,
    enableHiding: false,
    meta: { label: 'Details', fixed: true, cellClass: 'w-0 pr-0' },
  },
  ...columns,
]

const { table } = useDataTable<Payment>({ data: payments, columns: expandableColumns, getRowId: (row) => row.id, expandable: true })
</script>

<template>
  <DataTable :table="table" aria-label="Payments">
    <template #cell-expand="{ cell }">
      <DataTableExpandButton :row="cell.row" />
    </template>
    <template #expanded="{ row }">
      <FactList
        layout="rows"
        :facts="[
          { label: 'Method', value: row.method },
          { label: 'Email', value: row.email },
        ]"
      />
    </template>
  </DataTable>
</template>

Server-side sorting and pagination

With manual, the table stops sorting and slicing on its own and renders the rows as they arrive. Watch sorting and pagination and refetch: the server returns the page already sorted and sliced, with the total that goes to rowCount:

Method

No payments to show

'use client'

import { Badge } from '@/components/badge'
import { DataTable } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table-pagination'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { useEffect, useState } from 'react'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => <Badge color={statusColors[row.original.status]}>{row.original.status}</Badge>, meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

export default function Example() {
  let [page, setPage] = useState<{ rows: Payment[]; total: number }>({ rows: [], total: 0 })
  let { table, sorting, pagination } = useDataTable<Payment>({
    data: page.rows,
    columns,
    getRowId: (row) => row.id,
    manual: true,
    rowCount: page.total,
  })

  useEffect(() => {
    let current = true
    let params = new URLSearchParams({
      page: String(pagination.pageIndex + 1),
      perPage: String(pagination.pageSize),
      sort: sorting.map((sort) => (sort.desc ? '-' : '') + sort.id).join(','),
    })
    fetch(`/api/payments?${params}`)
      .then((response) => response.json())
      .then((next: { rows: Payment[]; total: number }) => {
        if (current) setPage(next)
      })
    return () => {
      current = false
    }
  }, [sorting, pagination])

  return (
    <>
      <DataTable table={table} aria-label="Payments" />
      <DataTablePagination table={table} noun="payments" className="mt-6" />
    </>
  )
}
<script setup lang="ts">
import Badge from '@/components/badge.vue'
import DataTablePagination from '@/components/data-table-pagination.vue'
import DataTable from '@/components/data-table.vue'
import { useDataTable } from '@/components/use-data-table'
import type { ColumnDef } from '@tanstack/vue-table'
import { h, ref, watchEffect } from 'vue'

type Payment = {
  id: string
  customer: string
  email: string
  amount: number
  status: 'succeeded' | 'pending' | 'refunded' | 'failed'
  method: string
  createdAt: string
}

const payments: Payment[] = [
  { id: 'pi_3Kx1', customer: 'Mariam Keita', email: 'mariam@example.com', amount: 8000, status: 'succeeded', method: 'Visa •••• 4242', createdAt: '2026-06-02' },
  { id: 'pi_3Kx2', customer: 'Chidi Okeke', email: 'chidi@example.com', amount: 14250, status: 'pending', method: 'Mastercard •••• 5100', createdAt: '2026-05-28' },
  { id: 'pi_3Kx3', customer: 'Lina Mansouri', email: 'lina@example.com', amount: 3200, status: 'failed', method: 'Bank transfer', createdAt: '2026-04-30' },
]

const statusColors = { succeeded: 'lime', pending: 'amber', refunded: 'zinc', failed: 'red' } as const

const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })

function formatAmount(amount: number) {
  return `$${(amount / 100).toFixed(2)}`
}

const columns: ColumnDef<Payment, unknown>[] = [
  { id: 'customer', accessorFn: (row) => row.customer, header: 'Customer', cell: ({ row }) => row.original.customer, meta: { label: 'Customer' } },
  { id: 'amount', accessorFn: (row) => row.amount, header: 'Amount', cell: ({ row }) => formatAmount(row.original.amount), meta: { label: 'Amount', cellClass: 'text-right' } },
  { id: 'status', accessorFn: (row) => row.status, header: 'Status', cell: ({ row }) => h(Badge, { color: statusColors[row.original.status] }, () => row.original.status), meta: { label: 'Status' } },
  { id: 'method', accessorFn: (row) => row.method, header: 'Method', cell: ({ row }) => row.original.method, enableSorting: false, meta: { label: 'Method' } },
  { id: 'createdAt', accessorFn: (row) => row.createdAt, header: 'Created', cell: ({ row }) => dateFormat.format(new Date(row.original.createdAt)), meta: { label: 'Created' } },
]

const page = ref<{ rows: Payment[]; total: number }>({ rows: [], total: 0 })

const { table, sorting, pagination } = useDataTable<Payment>({
  data: () => page.value.rows,
  columns,
  getRowId: (row) => row.id,
  manual: true,
  rowCount: () => page.value.total,
})

watchEffect(async (onCleanup) => {
  let current = true
  onCleanup(() => (current = false))
  const params = new URLSearchParams({
    page: String(pagination.value.pageIndex + 1),
    perPage: String(pagination.value.pageSize),
    sort: sorting.value.map((sort) => (sort.desc ? '-' : '') + sort.id).join(','),
  })
  const response = await fetch(`/api/payments?${params}`)
  const result: { rows: Payment[]; total: number } = await response.json()
  if (current) page.value = result
})
</script>

<template>
  <DataTable :table="table" aria-label="Payments" />
  <DataTablePagination :table="table" noun="payments" class="mt-6" />
</template>