Volt UI
Components

Data Table

A powerful data table component built on Tanstack Table with support for sorting, filtering, pagination, row selection, and virtualization.

FeatureStatus
Sorting
Filtering (global & column)
Pagination (client & server)
Row selection
Virtualization
Column visibility
Column resizing
Column pinning
Column reordering
Grouping
Expanding
Clickable rows
Cell overflow handling
Keyboard navigation
Sticky headers
Server-side data
Loading states
Error states

DataTable is built on Tanstack Table. For simple static tables without these features, use the base Table component instead.

Basic Usage

DataTable uses a composable API - wrap your content with DataTableContent to render the table.

InvoiceStatusTypeCustomerEmail
Amount
INV-0001PaidcreditJohn Doejohn.doe@example.com
€321.00
INV-0002PendingdebitJane Smithjane.smith@example.com
€106.00
INV-0003PendingdebitBob Wilsonbob.wilson@example.com
€348.00
INV-0004OverduecreditAlice Brownalice.brown@example.com
€421.00
INV-0005OverduecreditCharlie Davischarlie.davis@example.com
€399.00
INV-0006PendingcreditEva Martinezeva.martinez@example.com
€529.00
INV-0007PendingdebitFrank Millerfrank.miller@example.com
€546.00
INV-0008PendingcreditGrace Leegrace.lee@example.com
€299.00
INV-0009PaidcreditHenry Chenhenry.chen@example.com
€387.00
INV-0010PaidcreditIsabel Garciaisabel.garcia@example.com
€476.00
import { DataTable, DataTableContent } from "@epilot/volt-ui"
import type { ColumnDef } from "@tanstack/react-table"

type Invoice = {
  id: string
  status: string
  customer: string
  amount: number
}

const columns: ColumnDef<Invoice>[] = [
  { accessorKey: "id", header: "Invoice" },
  { accessorKey: "status", header: "Status" },
  { accessorKey: "customer", header: "Customer" },
  { accessorKey: "amount", header: "Amount" },
]

// Column resizing is enabled by default. Disable if you prefer fixed columns:
<DataTable columns={columns} data={invoices} disableColumnResizing>
  <DataTableContent />
</DataTable>

Full Examples

Client-Side

A complete example with sorting, row selection, column filtering, column visibility, pagination, and a toolbar with search.

Client-side example • 100 results
0 selected
InvoiceStatusTypeCustomerEmail
Amount
Actions
INV-0001PaidcreditJohn Doejohn.doe@example.com
€321.00
INV-0002PendingdebitJane Smithjane.smith@example.com
€106.00
INV-0003PendingdebitBob Wilsonbob.wilson@example.com
€348.00
INV-0004OverduecreditAlice Brownalice.brown@example.com
€421.00
INV-0005OverduecreditCharlie Davischarlie.davis@example.com
€399.00
INV-0006PendingcreditEva Martinezeva.martinez@example.com
€529.00
INV-0007PendingdebitFrank Millerfrank.miller@example.com
€546.00
INV-0008PendingcreditGrace Leegrace.lee@example.com
€299.00
INV-0009PaidcreditHenry Chenhenry.chen@example.com
€387.00
INV-0010PaidcreditIsabel Garciaisabel.garcia@example.com
€476.00
import { useState } from "react"
import type { ColumnFiltersState, SortingState } from "@tanstack/react-table"
import {
  DataTable,
  DataTableContent,
  DataTableHeader,
  DataTableBody,
  DataTablePagination,
  DataTableColumnVisibility,
  DataTableToolbar,
  Button,
} from "@epilot/volt-ui"

function FullExample() {
  const [globalFilter, setGlobalFilter] = useState("")
  const [rowSelection, setRowSelection] = useState({})
  const [sorting, setSorting] = useState<SortingState>([])
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])

  const statusFilter = columnFilters.find((f) => f.id === "status")?.value as string | undefined
  const typeFilter = columnFilters.find((f) => f.id === "type")?.value as string | undefined

  const setStatusFilter = (value: string | undefined) => {
    setColumnFilters((prev) =>
      value
        ? [...prev.filter((f) => f.id !== "status"), { id: "status", value }]
        : prev.filter((f) => f.id !== "status")
    )
  }

  const setTypeFilter = (value: string | undefined) => {
    setColumnFilters((prev) =>
      value
        ? [...prev.filter((f) => f.id !== "type"), { id: "type", value }]
        : prev.filter((f) => f.id !== "type")
    )
  }

  return (
    <DataTable
      columns={columns}
      data={data}
      enableSorting
      enableRowSelection
      enableGlobalFilter
      enableFiltering
      sorting={sorting}
      onSortingChange={setSorting}
      globalFilter={globalFilter}
      onGlobalFilterChange={setGlobalFilter}
      columnFilters={columnFilters}
      onColumnFiltersChange={setColumnFilters}
      rowSelection={rowSelection}
      onRowSelectionChange={setRowSelection}
      pagination={{ pageSize: 10 }}
    >
      <DataTableToolbar>
        <input
          type="text"
          placeholder="Search..."
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
        />
        <select
          value={statusFilter ?? ""}
          onChange={(e) => setStatusFilter(e.target.value || undefined)}
        >
          <option value="">All statuses</option>
          <option value="paid">Paid</option>
          <option value="pending">Pending</option>
        </select>
        <select
          value={typeFilter ?? ""}
          onChange={(e) => setTypeFilter(e.target.value || undefined)}
        >
          <option value="">All types</option>
          <option value="credit">Credit</option>
          <option value="debit">Debit</option>
        </select>
        {columnFilters.length > 0 && (
          <Button variant="tertiary" size="sm" onClick={() => setColumnFilters([])}>
            Clear filters
          </Button>
        )}
        <div className="flex-1" />
        <DataTableColumnVisibility label="Toggle columns" />
      </DataTableToolbar>
      <DataTableContent>
        <DataTableHeader />
        <DataTableBody />
      </DataTableContent>
      <DataTablePagination />
    </DataTable>
  )
}

Server-Side

A complete server-side example with manual sorting, filtering, and pagination.

Server-side example • 100 results
0 selected
InvoiceStatusTypeCustomerEmail
Amount
Actions
INV-0001PaidcreditJohn Doejohn.doe@example.com
€321.00
INV-0002PendingdebitJane Smithjane.smith@example.com
€106.00
INV-0003PendingdebitBob Wilsonbob.wilson@example.com
€348.00
INV-0004OverduecreditAlice Brownalice.brown@example.com
€421.00
INV-0005OverduecreditCharlie Davischarlie.davis@example.com
€399.00
INV-0006PendingcreditEva Martinezeva.martinez@example.com
€529.00
INV-0007PendingdebitFrank Millerfrank.miller@example.com
€546.00
INV-0008PendingcreditGrace Leegrace.lee@example.com
€299.00
INV-0009PaidcreditHenry Chenhenry.chen@example.com
€387.00
INV-0010PaidcreditIsabel Garciaisabel.garcia@example.com
€476.00
import { useState, useMemo } from "react"
import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table"
import {
  DataTable,
  DataTableContent,
  DataTableHeader,
  DataTableBody,
  DataTablePagination,
  DataTableColumnVisibility,
  DataTableToolbar,
  Button,
} from "@epilot/volt-ui"

function ServerExample() {
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  })
  const [sorting, setSorting] = useState<SortingState>([])
  const [globalFilter, setGlobalFilter] = useState("")
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
  const [rowSelection, setRowSelection] = useState({})

  const statusFilter = columnFilters.find((f) => f.id === "status")?.value as string | undefined
  const typeFilter = columnFilters.find((f) => f.id === "type")?.value as string | undefined

  const setStatusFilter = (value: string | undefined) => {
    setColumnFilters((prev) =>
      value
        ? [...prev.filter((f) => f.id !== "status"), { id: "status", value }]
        : prev.filter((f) => f.id !== "status")
    )
    setPagination((prev) => ({ ...prev, pageIndex: 0 }))
  }

  const setTypeFilter = (value: string | undefined) => {
    setColumnFilters((prev) =>
      value
        ? [...prev.filter((f) => f.id !== "type"), { id: "type", value }]
        : prev.filter((f) => f.id !== "type")
    )
    setPagination((prev) => ({ ...prev, pageIndex: 0 }))
  }

  // In a real app, use useQuery with pagination/sorting/filters as query keys
  const { data, totalItems } = useServerData({
    pagination,
    sorting,
    globalFilter,
    statusFilter,
    typeFilter,
  })

  return (
    <DataTable
      columns={columns}
      data={data}
      enableSorting
      manualSorting
      enableRowSelection
      enableGlobalFilter
      enableFiltering
      manualFiltering
      sorting={sorting}
      onSortingChange={setSorting}
      globalFilter={globalFilter}
      onGlobalFilterChange={setGlobalFilter}
      columnFilters={columnFilters}
      onColumnFiltersChange={setColumnFilters}
      rowSelection={rowSelection}
      onRowSelectionChange={setRowSelection}
      pagination={{
        pageIndex: pagination.pageIndex,
        pageSize: pagination.pageSize,
        totalItems: totalItems,
        onPaginationChange: setPagination,
      }}
    >
      <DataTableToolbar>
        <input
          type="text"
          placeholder="Search..."
          value={globalFilter}
          onChange={(e) => {
            setGlobalFilter(e.target.value)
            setPagination((prev) => ({ ...prev, pageIndex: 0 }))
          }}
        />
        <select
          value={statusFilter ?? ""}
          onChange={(e) => setStatusFilter(e.target.value || undefined)}
        >
          <option value="">All statuses</option>
          <option value="paid">Paid</option>
          <option value="pending">Pending</option>
        </select>
        <select
          value={typeFilter ?? ""}
          onChange={(e) => setTypeFilter(e.target.value || undefined)}
        >
          <option value="">All types</option>
          <option value="credit">Credit</option>
          <option value="debit">Debit</option>
        </select>
        {columnFilters.length > 0 && (
          <Button
            variant="tertiary"
            size="sm"
            onClick={() => {
              setColumnFilters([])
              setPagination((prev) => ({ ...prev, pageIndex: 0 }))
            }}
          >
            Clear filters
          </Button>
        )}
        <div className="flex-1" />
        <DataTableColumnVisibility label="Toggle columns" />
      </DataTableToolbar>
      <DataTableContent>
        <DataTableHeader />
        <DataTableBody />
      </DataTableContent>
      <DataTablePagination />
    </DataTable>
  )
}

Cell Overflow

Control how cell content handles overflow with the cellOverflow prop. This is useful when cells contain long text that would otherwise expand the column width.

  • grow (default) - Cells expand to fit content
  • wrap - Text wraps within the column width
  • truncate - Single line with ellipsis for overflow
cellOverflow="truncate" (table default)
Customer column has meta.cellOverflow="wrap" override
InvoiceCustomerEmailDescription
Amount
INV-0001John Doejohn.doe@example.comThis is a very long description that demonstrates how text truncation works when the content exceeds the column width. It should show an ellipsis.
€150.00
INV-0002Jane Smithjane.smith.at.a.very.long.company.name@longdomainexample.comAnother long description that will be truncated to fit within the available space in the cell.
€250.00
INV-0003Bob Wilson with a very long name that might need to wrap or truncatebob@example.comShort desc
€350.00
<DataTable columns={columns} data={data} cellOverflow="truncate">
  <DataTableContent />
</DataTable>

Per-Column Override

Override the table default for specific columns using meta.cellOverflow:

const columns: ColumnDef<Data>[] = [
  {
    accessorKey: "description",
    header: "Description",
    size: 200,
    meta: { cellOverflow: "wrap" }, // This column wraps, others truncate
  },
  {
    accessorKey: "email",
    header: "Email",
    // Uses table default
  },
]

<DataTable columns={columns} data={data} cellOverflow="truncate">
  <DataTableContent />
</DataTable>

For more advanced truncation like multi-line (line-clamp-2), use a custom cell renderer with your own CSS classes.

With Sorting

Enable sorting by setting enableSorting and using DataTableColumnHeader for sortable columns.

InvoiceStatusTypeCustomerEmail
Amount
INV-0001PaidcreditJohn Doejohn.doe@example.com
€321.00
INV-0002PendingdebitJane Smithjane.smith@example.com
€106.00
INV-0003PendingdebitBob Wilsonbob.wilson@example.com
€348.00
INV-0004OverduecreditAlice Brownalice.brown@example.com
€421.00
INV-0005OverduecreditCharlie Davischarlie.davis@example.com
€399.00
INV-0006PendingcreditEva Martinezeva.martinez@example.com
€529.00
INV-0007PendingdebitFrank Millerfrank.miller@example.com
€546.00
INV-0008PendingcreditGrace Leegrace.lee@example.com
€299.00
INV-0009PaidcreditHenry Chenhenry.chen@example.com
€387.00
INV-0010PaidcreditIsabel Garciaisabel.garcia@example.com
€476.00
import { DataTable, DataTableContent, DataTableColumnHeader } from "@epilot/volt-ui"
import type { ColumnDef } from "@tanstack/react-table"

const columns: ColumnDef<Invoice>[] = [
  {
    accessorKey: "id",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Invoice" />,
  },
  {
    accessorKey: "customer",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Customer" />,
  },
  // ...more columns
]

<DataTable columns={columns} data={data} enableSorting>
  <DataTableContent />
</DataTable>

Use DataTableFooter to add custom content between the table and pagination, such as "Show more" links or summary information.

InvoiceStatusTypeCustomerEmail
Amount
INV-0001PaidcreditJohn Doejohn.doe@example.com
€321.00
INV-0002PendingdebitJane Smithjane.smith@example.com
€106.00
INV-0003PendingdebitBob Wilsonbob.wilson@example.com
€348.00
INV-0004OverduecreditAlice Brownalice.brown@example.com
€421.00
INV-0005OverduecreditCharlie Davischarlie.davis@example.com
€399.00
import { useState } from "react"
import { DataTable, DataTableContent, DataTableFooter, Button } from "@epilot/volt-ui"

function MyTable() {
  const [pageSize, setPageSize] = useState(5)
  const visibleData = data.slice(0, pageSize)
  const hasMore = pageSize < data.length

  return (
    <DataTable columns={columns} data={visibleData}>
      <DataTableContent />
      {hasMore && (
        <DataTableFooter className="justify-start">
          <Button size="sm" variant="tertiary" onClick={() => setPageSize((prev) => prev + 3)}>
            Show more ({data.length - pageSize} remaining)
          </Button>
        </DataTableFooter>
      )}
    </DataTable>
  )
}

Column Definitions

DataTable uses Tanstack Table's column definition format. Here are common patterns:

Basic Column

{
  accessorKey: "name",
  header: "Name",
}

Custom Cell Rendering

{
  accessorKey: "status",
  header: "Status",
  cell: ({ row }) => <Badge>{row.getValue("status")}</Badge>,
}

Sortable Column

{
  accessorKey: "date",
  header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
}

Right-Aligned Column

{
  accessorKey: "amount",
  header: () => <div className="text-right">Amount</div>,
  cell: ({ row }) => (
    <div className="text-right tabular-nums">
      {formatCurrency(row.getValue("amount"))}
    </div>
  ),
  meta: { align: "right" },
}

Right-Aligned Sortable Column

{
  accessorKey: "amount",
  header: ({ column }) => (
    <div className="flex justify-end">
      <DataTableColumnHeader column={column} title="Amount" />
    </div>
  ),
  cell: ({ row }) => (
    <div className="text-right tabular-nums">
      {formatCurrency(row.getValue("amount"))}
    </div>
  ),
  meta: { align: "right" }, // Ensures proper header padding alignment
}

API Reference

See the full API Reference for all props and configuration options.