Data table
A table wired to TanStack Table: sorting, pagination, row selection and reorderable columns.
| Method | ||||
|---|---|---|---|---|
| Mariam Keita | $80.00 | succeeded | Visa •••• 4242 | Jun 2, 2026 |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 |
| Salma Bennani | $54.00 | failed | Wave | Apr 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/utilitiespnpm add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesyarn add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesbun add @tanstack/react-table @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesnpm install @tanstack/vue-table vuedraggablepnpm add @tanstack/vue-table vuedraggableyarn add @tanstack/vue-table vuedraggablebun add @tanstack/vue-table vuedraggableOnly 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>
)
}Also copy Table, Button, Checkbox, Pagination.
<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>Also copy Table, Button, Checkbox, Pagination.
Component API
| Prop | Default | Description |
|---|---|---|
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. |
| Prop | Default | Description |
|---|---|---|
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.
| Prop | Default | Description |
|---|---|---|
| useDataTable(options) | ||
expandable | false | Lets 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. |
pageSize | 10 | Rows per page. |
defaultSorting | [] | Sorting applied on first render. |
defaultVisibility | {} | Columns hidden until the picker is touched. |
manual | false | Sorting 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. |
| Prop | Default | Description |
|---|---|---|
| useDataTable(options) | ||
expandable | false | Lets 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. |
pageSize | 10 | Rows per page. |
defaultSorting | [] | Sorting applied on first render. |
defaultVisibility | {} | Columns hidden until the picker is touched. |
manual | false | Sorting 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.sortingandpagination: the current state, to forward to a server in manual mode.selectedRows: the selected rows themselves, not their ids.isCustomizedandresetColumns: 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 | succeeded | Visa •••• 4242 | Jun 2, 2026 |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 |
| Salma Bennani | $54.00 | failed | Wave | Apr 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 | succeeded | Visa •••• 4242 | Jun 2, 2026 |
Chidi Okeke chidi@example.com | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 |
Lina Mansouri lina@example.com | $32.00 | pending | Orange Money | May 19, 2026 |
Serge Ateba serge@example.com | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 |
Salma Bennani salma@example.com | $54.00 | failed | Wave | Apr 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>Rows as links
rowHref turns each row into a link: the whole row, not just one cell, so the pointer target matches what the eye sees:
'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 | succeeded | Visa •••• 4242 | Jun 2, 2026 | |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 | |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 | |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 | |
| Salma Bennani | $54.00 | failed | Wave | Apr 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 | succeeded | Jun 2, 2026 |
| Chidi Okeke | $142.50 | succeeded | May 28, 2026 |
| Lina Mansouri | $32.00 | pending | May 19, 2026 |
| Serge Ateba | $276.00 | refunded | May 9, 2026 |
| Salma Bennani | $54.00 | failed | Apr 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 | succeeded | Visa •••• 4242 | Jun 2, 2026 |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 |
| Salma Bennani | $54.00 | failed | Wave | Apr 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 | succeeded | Visa •••• 4242 | Jun 2, 2026 |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 9, 2026 |
| Salma Bennani | $54.00 | failed | Wave | Apr 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 | succeeded | Visa •••• 4242 | Jun 2, 2026 | |
| Chidi Okeke | $142.50 | succeeded | Mastercard •••• 5100 | May 28, 2026 | |
| Lina Mansouri | $32.00 | pending | Orange Money | May 19, 2026 | |
| Serge Ateba | $276.00 | refunded | Visa •••• 1881 | May 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>