HextaUI

Data table

A table for real data, with sorting, search, row selection, pinned columns, a sticky header and pagination.

MethodCountry
[email protected]paidCardUnited States2026-01-01$5.00
[email protected]pendingPayPalJapan2026-02-02$84.20
[email protected]failedBankGermany2026-03-03$163.40
[email protected]paidApple PayBrazil2026-04-04$242.60
[email protected]pendingCardIndia2026-05-05$321.80
[email protected]failedPayPalUnited States2026-06-06$401.00
[email protected]paidBankJapan2026-07-07$480.10
[email protected]pendingApple PayGermany2026-08-08$559.30
[email protected]failedCardBrazil2026-09-09$638.50
[email protected]paidPayPalIndia2026-01-10$717.70
0 of 42 rows selected
Page 1 of 5
"use client"

import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments = makePayments(42)

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

export function DataTableDemo() {
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <DataTable table={table}>
      <DataTableToolbar>
        <DataTableSearch placeholder="Search payments…" />
        <DataTableViewOptions />
      </DataTableToolbar>
      <DataTableContent emptyMessage="No payments match your search." />
      <DataTablePagination />
    </DataTable>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/data-table.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

The data table is TanStack Table v9 with the sorting, filtering, pagination, selection and column visibility features already wired up. Define columns once with createDataTableColumns, create the table with useDataTable, then compose the parts you need.

import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"
type Payment = { id: string; email: string; amount: number }

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("amount", {
    header: "Amount",
    meta: { align: "end" },
  }),
])
export function Payments({ data }: { data: Payment[] }) {
  const table = useDataTable({
    data,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <DataTable table={table}>
      <DataTableToolbar>
        <DataTableSearch placeholder="Search payments…" />
        <DataTableViewOptions />
      </DataTableToolbar>
      <DataTableContent />
      <DataTablePagination />
    </DataTable>
  )
}

Pass any TanStack state you want to own, such as sorting or row selection, with its change handler.

const [sorting, setSorting] = React.useState([{ id: "date", desc: true }])

const table = useDataTable({
  data,
  columns,
  state: { sorting },
  onSortingChange: setSorting,
})
DataTable
├── DataTableToolbar
│   ├── DataTableSearch
│   └── DataTableViewOptions
├── DataTableContent
└── DataTablePagination

DataTableColumnHeader, DataTableSelectAll and DataTableSelectRow go in your column definitions, as a column’s header or cell.

Plain table

The plain <Table /> parts the data table is built from. Use them on their own for static data, with a caption and a footer total.

A list of your recent invoices.
InvoiceStatusMethodAmount
INV001PaidCredit card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank transfer$350.00
INV004PaidCredit card$450.00
Total$1,200.00
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const invoices = [
  { id: "INV001", status: "Paid", method: "Credit card", amount: 250 },
  { id: "INV002", status: "Pending", method: "PayPal", amount: 150 },
  { id: "INV003", status: "Unpaid", method: "Bank transfer", amount: 350 },
  { id: "INV004", status: "Paid", method: "Credit card", amount: 450 },
]

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

export function TableDemo() {
  const total = invoices.reduce((sum, invoice) => sum + invoice.amount, 0)

  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead align="end">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableCell>{invoice.id}</TableCell>
            <TableCell>{invoice.status}</TableCell>
            <TableCell>{invoice.method}</TableCell>
            <TableCell align="end">{currency.format(invoice.amount)}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell align="end">{currency.format(total)}</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}

Loading

With loading, skeleton rows fill the same space as real rows, so nothing jumps when the data arrives. The table is marked aria-busy meanwhile.

MethodCountry
0 of 5 rows selected
Page 1 of 1
"use client"

import * as React from "react"

import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments = makePayments(5)

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

export function DataTableLoading() {
  const [loading, setLoading] = React.useState(true)
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <div className="flex w-full flex-col items-start gap-3">
      <Button
        variant="outline"
        size="sm"
        onClick={() => setLoading((value) => !value)}
      >
        {loading ? "Finish loading" : "Load again"}
      </Button>
      <DataTable table={table}>
        <DataTableToolbar>
          <DataTableSearch placeholder="Search payments…" />
          <DataTableViewOptions />
        </DataTableToolbar>
        <DataTableContent loading={loading} />
        <DataTablePagination />
      </DataTable>
    </div>
  )
}

Empty

emptyMessage fills the body when there is no data or nothing matches the search.

MethodCountry
No payments yet.
0 of 0 rows selected
Page 1 of 1
"use client"

import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments: Payment[] = []

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

export function DataTableEmpty() {
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <DataTable table={table}>
      <DataTableToolbar>
        <DataTableSearch placeholder="Search payments…" />
        <DataTableViewOptions />
      </DataTableToolbar>
      <DataTableContent emptyMessage="No payments yet." />
      <DataTablePagination />
    </DataTable>
  )
}

Give the container a max height with containerClassName and set stickyHeader. The header stays put and gains a hairline once the rows scroll under it. Shift-drag over the checkboxes scrolls the box as you near its edge.

MethodCountry
[email protected]paidCardUnited States2026-01-01$5.00
[email protected]pendingPayPalJapan2026-02-02$84.20
[email protected]failedBankGermany2026-03-03$163.40
[email protected]paidApple PayBrazil2026-04-04$242.60
[email protected]pendingCardIndia2026-05-05$321.80
[email protected]failedPayPalUnited States2026-06-06$401.00
[email protected]paidBankJapan2026-07-07$480.10
[email protected]pendingApple PayGermany2026-08-08$559.30
[email protected]failedCardBrazil2026-09-09$638.50
[email protected]paidPayPalIndia2026-01-10$717.70
[email protected]pendingBankUnited States2026-02-11$796.90
[email protected]failedApple PayJapan2026-03-12$876.10
[email protected]paidCardGermany2026-04-13$955.30
[email protected]pendingPayPalBrazil2026-05-14$34.50
[email protected]failedBankIndia2026-06-15$113.70
[email protected]paidApple PayUnited States2026-07-16$192.90
[email protected]pendingCardJapan2026-08-17$272.00
[email protected]failedPayPalGermany2026-09-18$351.20
[email protected]paidBankBrazil2026-01-19$430.40
[email protected]pendingApple PayIndia2026-02-20$509.60
[email protected]failedCardUnited States2026-03-21$588.80
[email protected]paidPayPalJapan2026-04-22$668.00
[email protected]pendingBankGermany2026-05-23$747.20
[email protected]failedApple PayBrazil2026-06-24$826.40
[email protected]paidCardIndia2026-07-25$905.60
[email protected]pendingPayPalUnited States2026-08-26$984.80
[email protected]failedBankJapan2026-09-27$63.90
[email protected]paidApple PayGermany2026-01-01$143.10
[email protected]pendingCardBrazil2026-02-02$222.30
[email protected]failedPayPalIndia2026-03-03$301.50
[email protected]paidBankUnited States2026-04-04$380.70
[email protected]pendingApple PayJapan2026-05-05$459.90
[email protected]failedCardGermany2026-06-06$539.10
[email protected]paidPayPalBrazil2026-07-07$618.30
[email protected]pendingBankIndia2026-08-08$697.50
[email protected]failedApple PayUnited States2026-09-09$776.70
[email protected]paidCardJapan2026-01-10$855.80
[email protected]pendingPayPalGermany2026-02-11$935.00
[email protected]failedBankBrazil2026-03-12$14.20
[email protected]paidApple PayIndia2026-04-13$93.40
[email protected]pendingCardUnited States2026-05-14$172.60
[email protected]failedPayPalJapan2026-06-15$251.80
[email protected]paidBankGermany2026-07-16$331.00
[email protected]pendingApple PayBrazil2026-08-17$410.20
[email protected]failedCardIndia2026-09-18$489.40
[email protected]paidPayPalUnited States2026-01-19$568.60
[email protected]pendingBankJapan2026-02-20$647.70
[email protected]failedApple PayGermany2026-03-21$726.90
[email protected]paidCardBrazil2026-04-22$806.10
[email protected]pendingPayPalIndia2026-05-23$885.30
[email protected]failedBankUnited States2026-06-24$964.50
[email protected]paidApple PayJapan2026-07-25$43.70
[email protected]pendingCardGermany2026-08-26$122.90
[email protected]failedPayPalBrazil2026-09-27$202.10
[email protected]paidBankIndia2026-01-01$281.30
[email protected]pendingApple PayUnited States2026-02-02$360.50
[email protected]failedCardJapan2026-03-03$439.60
[email protected]paidPayPalGermany2026-04-04$518.80
[email protected]pendingBankBrazil2026-05-05$598.00
[email protected]failedApple PayIndia2026-06-06$677.20
[email protected]paidCardUnited States2026-07-07$756.40
[email protected]pendingPayPalJapan2026-08-08$835.60
[email protected]failedBankGermany2026-09-09$914.80
[email protected]paidApple PayBrazil2026-01-10$994.00
[email protected]pendingCardIndia2026-02-11$73.20
[email protected]failedPayPalUnited States2026-03-12$152.40
[email protected]paidBankJapan2026-04-13$231.50
[email protected]pendingApple PayGermany2026-05-14$310.70
[email protected]failedCardBrazil2026-06-15$389.90
[email protected]paidPayPalIndia2026-07-16$469.10
[email protected]pendingBankUnited States2026-08-17$548.30
[email protected]failedApple PayJapan2026-09-18$627.50
[email protected]paidCardGermany2026-01-19$706.70
[email protected]pendingPayPalBrazil2026-02-20$785.90
[email protected]failedBankIndia2026-03-21$865.10
[email protected]paidApple PayUnited States2026-04-22$944.30
[email protected]pendingCardJapan2026-05-23$23.40
[email protected]failedPayPalGermany2026-06-24$102.60
[email protected]paidBankBrazil2026-07-25$181.80
[email protected]pendingApple PayIndia2026-08-26$261.00
[email protected]failedCardUnited States2026-09-27$340.20
[email protected]paidPayPalJapan2026-01-01$419.40
[email protected]pendingBankGermany2026-02-02$498.60
[email protected]failedApple PayBrazil2026-03-03$577.80
[email protected]paidCardIndia2026-04-04$657.00
[email protected]pendingPayPalUnited States2026-05-05$736.20
[email protected]failedBankJapan2026-06-06$815.30
[email protected]paidApple PayGermany2026-07-07$894.50
[email protected]pendingCardBrazil2026-08-08$973.70
[email protected]failedPayPalIndia2026-09-09$52.90
[email protected]paidBankUnited States2026-01-10$132.10
[email protected]pendingApple PayJapan2026-02-11$211.30
[email protected]failedCardGermany2026-03-12$290.50
[email protected]paidPayPalBrazil2026-04-13$369.70
[email protected]pendingBankIndia2026-05-14$448.90
[email protected]failedApple PayUnited States2026-06-15$528.10
[email protected]paidCardJapan2026-07-16$607.20
[email protected]pendingPayPalGermany2026-08-17$686.40
[email protected]failedBankBrazil2026-09-18$765.60
[email protected]paidApple PayIndia2026-01-19$844.80
0 of 1,000 rows selected
Page 1 of 10
"use client"

import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments = makePayments(1000)

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

export function DataTableStickyHeader() {
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
    initialState: { pagination: { pageIndex: 0, pageSize: 100 } },
  })

  return (
    <DataTable table={table}>
      <DataTableToolbar>
        <DataTableSearch placeholder="Search 1,000 payments…" />
        <DataTableViewOptions />
      </DataTableToolbar>
      <DataTableContent stickyHeader containerClassName="max-h-80" />
      <DataTablePagination />
    </DataTable>
  )
}

Pinned columns

The selection column and the first data column are pinned by default. Choose your own with pinStart. A soft shadow marks the edge once the table scrolls sideways.

MethodCountry
[email protected]paidCardUnited States2026-01-01$5.00
[email protected]pendingPayPalJapan2026-02-02$84.20
[email protected]failedBankGermany2026-03-03$163.40
[email protected]paidApple PayBrazil2026-04-04$242.60
[email protected]pendingCardIndia2026-05-05$321.80
[email protected]failedPayPalUnited States2026-06-06$401.00
[email protected]paidBankJapan2026-07-07$480.10
[email protected]pendingApple PayGermany2026-08-08$559.30
[email protected]failedCardBrazil2026-09-09$638.50
[email protected]paidPayPalIndia2026-01-10$717.70
0 of 12 rows selected
Page 1 of 2
"use client"

import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments = makePayments(12)

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

export function DataTablePinnedColumns() {
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <div className="w-full max-w-md">
      <DataTable table={table}>
        <DataTableToolbar>
          <DataTableSearch placeholder="Search payments…" />
          <DataTableViewOptions />
        </DataTableToolbar>
        <DataTableContent pinStart={["select", "email", "status"]} />
        <DataTablePagination />
      </DataTable>
    </div>
  )
}

Right to left

Every label and count can be replaced with labels and the format functions. Pagination arrows and pinned columns flip with the direction.

MethodCountry
[email protected]paidCardUnited States2026-01-01$5.00
[email protected]pendingPayPalJapan2026-02-02$84.20
[email protected]failedBankGermany2026-03-03$163.40
[email protected]paidApple PayBrazil2026-04-04$242.60
[email protected]pendingCardIndia2026-05-05$321.80
[email protected]failedPayPalUnited States2026-06-06$401.00
تم تحديد 0 من 6
صفحة 1 من 1
"use client"

import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  createDataTableColumns,
  DataTable,
  DataTableColumnHeader,
  DataTableContent,
  DataTablePagination,
  DataTableSearch,
  DataTableSelectAll,
  DataTableSelectRow,
  DataTableToolbar,
  DataTableViewOptions,
  useDataTable,
} from "@/components/ui/data-table"

type Payment = {
  id: string
  email: string
  status: "paid" | "pending" | "failed"
  amount: number
  method: string
  country: string
  date: string
}

const statuses = ["paid", "pending", "failed"] as const
const methods = ["Card", "PayPal", "Bank", "Apple Pay"]
const countries = ["United States", "Germany", "India", "Japan", "Brazil"]
const names = ["olivia", "jackson", "isabella", "william", "sofia", "liam"]

function makePayments(count: number): Payment[] {
  return Array.from({ length: count }, (_, index) => ({
    id: `pay_${String(index + 1).padStart(4, "0")}`,
    email: `${names[index % names.length]}.${Math.floor(index / names.length) + 1}@example.com`,
    status: statuses[(index * 7) % 3],
    amount: Math.round(((index * 7919) % 100000) / 10) / 10 + 5,
    method: methods[index % methods.length],
    country: countries[(index * 3) % countries.length],
    date: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
  }))
}

const payments = makePayments(6)

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const statusVariant = {
  paid: "success",
  pending: "warning",
  failed: "destructive",
} as const

const helper = createDataTableColumns<Payment>()

const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => <DataTableSelectAll table={table} />,
    cell: ({ row }) => <DataTableSelectRow row={row} />,
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("email", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Email" />
    ),
  }),
  helper.accessor("status", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Status" />
    ),
    cell: ({ getValue }) => {
      const status = getValue()
      return (
        <Badge variant={statusVariant[status]}>
          <BadgeDot />
          <span className="capitalize">{status}</span>
        </Badge>
      )
    },
  }),
  helper.accessor("method", { header: "Method" }),
  helper.accessor("country", { header: "Country" }),
  helper.accessor("date", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Date" />
    ),
  }),
  helper.accessor("amount", {
    header: ({ column }) => (
      <DataTableColumnHeader column={column} title="Amount" />
    ),
    cell: ({ getValue }) => currency.format(getValue()),
    meta: { align: "end" },
  }),
])

const arabicNumber = new Intl.NumberFormat("ar")

export function DataTableRtl() {
  const table = useDataTable({
    data: payments,
    columns,
    getRowId: (row) => row.id,
  })

  return (
    <div dir="rtl" className="w-full">
      <DataTable table={table}>
        <DataTableToolbar>
          <DataTableSearch placeholder="ابحث في المدفوعات…" />
          <DataTableViewOptions label="عرض" groupLabel="إظهار الأعمدة" />
        </DataTableToolbar>
        <DataTableContent emptyMessage="لا توجد نتائج." />
        <DataTablePagination
          labels={{
            rowsPerPage: "صفوف في الصفحة",
            firstPage: "الصفحة الأولى",
            previousPage: "الصفحة السابقة",
            nextPage: "الصفحة التالية",
            lastPage: "الصفحة الأخيرة",
          }}
          formatSelection={(selected, total) =>
            `تم تحديد ${arabicNumber.format(selected)} من ${arabicNumber.format(total)}`
          }
          formatPage={(page, count) =>
            `صفحة ${arabicNumber.format(page)} من ${arabicNumber.format(count)}`
          }
        />
      </DataTable>
    </div>
  )
}
  • Click a sortable header to sort ascending, again for descending, and a third time to clear. Shift-click another header to add a secondary sort.
  • Shift-click a row checkbox to select every row between it and the last one you clicked. Hold Shift and drag across checkboxes to select or clear a range in one motion.
  • Searching matches every column except the selection column and jumps back to the first page.
  • Hiding columns from the View menu keeps the menu open, so you can toggle several at once.
KeyAction
TabMoves through the search, View menu, sortable headers, row checkboxes and pagination.
EnterSpaceSorts by the focused header.
SpaceToggles the focused checkbox.
EscClears the search when it has text.
  • Sortable headers carry aria-sort, and a polite live region announces the new sort and, shortly after typing, the number of results.
  • The page indicator is a live region, so screen readers hear the new page after pressing next or previous.
  • Checkboxes have labels by default. Override them with aria-label on <DataTableSelectAll /> and <DataTableSelectRow />.

Every part below has to be rendered inside <DataTable />, which shares the table with them.

Takes the TanStack Table options and returns the table. Pages hold 10 rows unless initialState.pagination says otherwise.

PropTypeDefault
data
TData[]–
columnsBuild them with createDataTableColumns.
ColumnDef[]–
getRowIdKeeps selection stable when rows move. Defaults to the row index.
(row: TData) => string–
initialState
Partial<TableState>{ pagination: { pageIndex: 0, pageSize: 10 } }
stateControl sorting, rowSelection, globalFilter, pagination or columnVisibility.
Partial<TableState>–
onSortingChangeEach controllable state has a matching handler, like onRowSelectionChange.
OnChangeFn<SortingState>–
enableRowSelection
boolean | (row) => booleantrue

Returns a typed column helper with accessor, display and columns. Set meta: { align: "end" } on numeric columns to align the header and cells.

PropTypeDefault
tableThe table returned by useDataTable.
DataTableInstance<TData>–
className
string–
AttributeDescription
data-slot="data-table"The wrapper around every part.
data-slot="data-table-announcer"The visually hidden live region.
PropTypeDefault
emptyMessage
ReactNode"No results."
loading
booleanfalse
loadingRowsNumber of skeleton rows while loading.
number5
pinStartColumn ids to pin to the start edge.
string[]["select", firstColumnId]
stickyHeaderNeeds a max height on the container.
booleanfalse
containerClassNameApplied to the scroll container.
string–
swipeSelectShift-drag across checkboxes to select a range.
booleantrue
classNameApplied to the table element.
string–
AttributeDescription
data-slot="table-container"The scroll container.
data-scrolled-startPresent on the container when it has scrolled away from the start edge.
data-scrolled-endPresent while there is more to scroll toward the end edge.
data-scrolled-topPresent once the rows scroll vertically.
data-swipe-selectingPresent on the container during a shift-drag.
data-state="selected"Present on selected rows.
data-row-idThe row id from getRowId.
data-slot="data-table-loading-row"Each skeleton row.
data-slot="data-table-empty"The empty row.

A wrapping row for the search, the View menu and your own filters. Accepts every div prop and carries data-table-toolbar as its data-slot.

PropTypeDefault
placeholder
string"Search…"
aria-label
string"Search table"
clearLabelAccessible name of the clear button.
string"Clear search"
AttributeDescription
data-slot="data-table-search"The search field wrapper.
PropTypeDefault
label
string"View"
groupLabel
string"Toggle columns"
getLabelDefaults to the column’s string header, or its id capitalized.
(column) => string–

Lists every column that can hide. Set enableHiding: false on a column to leave it out.

AttributeDescription
data-slot="data-table-view-options"The menu popup.
PropTypeDefault
column
Column–
title
string–

Renders a sort button for sortable columns and plain text for the rest.

AttributeDescription
data-slot="data-table-column-header"The header wrapper.
data-sortedPresent on the sort button while the column is sorted.
aria-sortOn the header cell: ascending, descending or none.
PropTypeDefault
table
Table–
aria-label
string"Select all rows on this page"

Selects the rows on the current page and shows an indeterminate state when only some are selected.

PropTypeDefault
row
Row–
aria-label
string"Select row"
PropTypeDefault
pageSizes
number[][10, 20, 50, 100]
showSelectionShow the selected count instead of the row count.
booleantrue
labelsrowsPerPage, firstPage, previousPage, nextPage and lastPage.
Partial<DataTablePaginationLabels>–
formatSelection
(selected: number, total: number) => ReactNode"2 of 42 rows selected"
formatRows
(total: number) => ReactNode"42 rows"
formatPage
(page: number, pageCount: number) => ReactNode"Page 1 of 5"
AttributeDescription
data-slot="data-table-pagination"The pagination bar.

Returns the table from the nearest <DataTable />. Use it to build your own toolbar controls, such as a status filter.

PropTypeDefault
stickyHeaderPins the header row inside a scrolling container.
booleanfalse
containerClassNameApplied to the scroll container.
string–
containerRef
Ref<HTMLDivElement>–
AttributeDescription
data-slot="table"The table element.
data-sticky-headerPresent on the container when stickyHeader is on.
--table-bgRow and pinned-cell background. Follows the card or popover it sits in.
PropTypeDefault
alignEnd-aligned cells also use tabular numbers.
"start" | "center" | "end""start"
pinnedKeeps the cell in place while the table scrolls sideways. Offset with --pin-offset.
"start" | "end"–
pinnedEdgeDraws a soft shadow on the last pinned column while scrolled.
booleanfalse
AttributeDescription
data-alignThe current alignment.
data-pinnedstart or end when pinned.
data-pinned-edgePresent on the last pinned cell of a side.
--pin-offsetDistance from the pinned edge, set for you.

TableHeader, TableBody, TableFooter, TableRow and TableCaption render the matching table elements and accept all of their props.