DataTable

DataTable displays tabular data with sorting, pagination, selection, and editable cells.

Basics

Define columns, feed the dataset, and let DataTable handle search, sorting, and pagination out of the box.

Loading demo…

import { useState } from 'react'; import { DataTable } from '@plocks/ui'; import type { DataTableColumn, DataTablePagination, DataTableSort } from '@plocks/ui'; import { people, type Person } from '../data'; const columns: DataTableColumn<Person>[] = [ { key: 'name', header: 'Name', accessor: 'name', sortable: true }, { key: 'email', header: 'Email', accessor: 'email', sortable: true, minWidth: 200 }, { key: 'title', header: 'Role', accessor: 'title', sortable: true }, { key: 'department', header: 'Department', accessor: 'department', sortable: true, minWidth: 160 }, ]; export function Demo() { const [sortBy, setSortBy] = useState<DataTableSort[]>([]); const [pagination, setPagination] = useState<DataTablePagination>({ page: 1, pageSize: 5, total: people.length, }); return ( <DataTable data={people} columns={columns} sortBy={sortBy} onSortChange={setSortBy} pagination={pagination} onPaginationChange={setPagination} searchable searchPlaceholder="Search teammates" /> ); }

Column Filters

Mark columns filterable and set filterType to pick the control: an input for text/number/date, a dropdown for select/boolean (options auto-derived from the data when filterOptions is omitted). showColumnFilters renders those controls as a persistent row under the headers; omit it to keep them in each header's filter menu.

Loading demo…

import { useState } from 'react'; import { DataTable } from '@plocks/ui'; import type { DataTableColumn, DataTableFilter } from '@plocks/ui'; import { departmentFilterOptions, people, statusFilterOptions, type Person } from '../data'; const columns: DataTableColumn<Person>[] = [ // text → inline text input { key: 'name', header: 'Name', accessor: 'name', sortable: true, filterable: true, filterType: 'text' }, // select with explicit options → dropdown { key: 'department', header: 'Department', accessor: 'department', sortable: true, filterable: true, filterType: 'select', filterOptions: departmentFilterOptions, }, { key: 'status', header: 'Status', accessor: 'status', sortable: true, filterable: true, filterType: 'select', filterOptions: statusFilterOptions, }, // boolean → Yes / No / All dropdown { key: 'remote', header: 'Remote', accessor: 'remote', filterable: true, filterType: 'boolean', cell: (v) => (v ? 'Yes' : 'No') }, // number → inline numeric input { key: 'salary', header: 'Salary', accessor: 'salary', sortable: true, filterable: true, filterType: 'number', dataType: 'currency', align: 'right', }, ]; export function Demo() { const [filters, setFilters] = useState<DataTableFilter[]>([]); return ( <DataTable data={people} columns={columns} filters={filters} onFilterChange={setFilters} showColumnFilters searchable={false} /> ); }

Variants

Compare the default, striped, and bordered table treatments using the same rows.

Loading demo…

import { Column, DataTable, Text } from '@plocks/ui'; const rows = [ { id: 1, name: 'Avery', role: 'Designer' }, { id: 2, name: 'Jordan', role: 'Engineer' }, ]; const columns = [ { key: 'name', header: 'Name', accessor: 'name' as const }, { key: 'role', header: 'Role', accessor: 'role' as const }, ]; const variants = ['default', 'striped', 'bordered'] as const; export function Demo() { return ( <Column gap="lg" fullWidth> {variants.map(variant => ( <Column key={variant} gap="xs" fullWidth> <Text fw="semibold">{variant}</Text> <DataTable data={rows} columns={columns} variant={variant} /> </Column> ))} </Column> ); }

Row Selection

Set selectable and wire selectedRows / onSelectionChange to track checked rows. Pass a stable getRowId so selection survives sorting and paging; the table requires it for selection.

Loading demo…

import { useState } from 'react'; import { Block, DataTable, Text } from '@plocks/ui'; import type { DataTableColumn, DataTablePagination } from '@plocks/ui'; import { people, type Person } from '../data'; const columns: DataTableColumn<Person>[] = [ { key: 'name', header: 'Name', accessor: 'name', sortable: true }, { key: 'email', header: 'Email', accessor: 'email', sortable: true, minWidth: 200 }, { key: 'role', header: 'Role', accessor: 'role', sortable: true }, ]; export function Demo() { const [pagination, setPagination] = useState<DataTablePagination>({ page: 1, pageSize: 5, total: people.length, }); const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]); return ( <Block fullWidth> <Text size="sm" c={selectedRows.length ? 'primary' : 'muted'}> {selectedRows.length ? `${selectedRows.length} selected` : 'No rows selected'} </Text> <DataTable data={people} columns={columns} pagination={pagination} onPaginationChange={setPagination} selectable selectedRows={selectedRows} onSelectionChange={setSelectedRows} getRowId={(row) => row.id} searchable={false} /> </Block> ); }

Rich Cells

Combine avatars, chips, and status cues inside custom cell renderers to create a readable, on-brand table.

Loading demo…

import { Avatar, Chip, DataTable, Text } from '@plocks/ui'; import type { DataTableColumn } from '@plocks/ui'; import { people, type Person } from '../data'; const rows = people.slice(0, 5); const columns: DataTableColumn<Person>[] = [ { key: 'name', header: 'Teammate', accessor: 'name', sortable: true, cell: (_value, row) => ( <Avatar size="sm" fallback={row.name .split(' ') .map((part) => part[0]) .join('')} label={<Text fw="semibold">{row.name}</Text>} description={<Text variant="small" c="muted">{row.title}</Text>} gap={8} /> ), }, { key: 'team', header: 'Team', accessor: 'team', sortable: true, cell: (value) => ( <Chip size="xs" color="primary" variant="light"> {value} </Chip> ), }, { key: 'status', header: 'Status', accessor: 'status', sortable: true, cell: (value: Person['status']) => ( <Text c={value === 'inactive' ? 'error' : value === 'pending' ? 'warning' : 'success'} fw="semibold" > {value.charAt(0).toUpperCase() + value.slice(1)} </Text> ), }, { key: 'performance', header: 'Score', accessor: 'performance', sortable: true, align: 'right', cell: (value) => <Text fw="semibold">{value.toFixed(1)}</Text>, }, ]; export function Demo() { return ( <DataTable data={rows} columns={columns} density="comfortable" variant="striped" searchable={false} /> ); }

Expandable Rows

Provide expandedRows, update them via onExpandedRowsChange, and use expandableRowRender to reveal supporting context.

Loading demo…

import { useState } from 'react'; import { Block, DataTable, Text } from '@plocks/ui'; import type { DataTableColumn } from '@plocks/ui'; import { projects, type Project } from '../data'; const columns: DataTableColumn<Project>[] = [ { key: 'name', header: 'Project', accessor: 'name', sortable: true }, { key: 'owner', header: 'Owner', accessor: 'owner', sortable: true }, { key: 'budget', header: 'Budget', accessor: 'budget', align: 'right', sortable: true, dataType: 'currency', }, ]; export function Demo() { const [expandedRows, setExpandedRows] = useState<(string | number)[]>([projects[0].id]); return ( <DataTable data={projects} columns={columns} getRowId={(row) => row.id} expandedRows={expandedRows} onExpandedRowsChange={setExpandedRows} expandableRowRender={(project) => ( <Block p="md"> <Text c="muted">{project.summary}</Text> </Block> )} searchable={false} /> ); }

Grouping & Totals

Set groupBy to a column key to group rows under collapsible group-header rows. Add aggregate (sum, avg, min, max, count, or a function) to any column to show its per-group total in the group header, and set showFooterTotals for a grand-total footer row aligned to the same columns. Grouping spans all filtered rows, so client pagination is bypassed while it is active.

Loading demo…

import { DataTable } from '@plocks/ui'; import type { DataTableColumn } from '@plocks/ui'; import { sales as rows, type Sale } from '../data'; const columns: DataTableColumn<Sale>[] = [ { key: 'region', header: 'Region', accessor: 'region' }, { key: 'rep', header: 'Rep', accessor: 'rep', aggregate: 'count' }, { key: 'product', header: 'Product', accessor: 'product' }, { key: 'units', header: 'Units', accessor: 'units', dataType: 'number', align: 'right', aggregate: 'sum' }, { key: 'revenue', header: 'Revenue', accessor: 'revenue', dataType: 'currency', align: 'right', aggregate: 'sum' }, ]; export function Demo() { return ( <DataTable data={rows} columns={columns} groupBy="region" showFooterTotals footerLabel="All regions" searchable={false} showColumnVisibilityManager={false} /> ); }

Fixed height & sticky columns

Pass a fixed h to cap the table's size — the header row stays pinned while the body scrolls, so a long list fits a constrained panel without paginating. Pin columns to the edges with sticky: 'left' or sticky: 'right' so they stay put while the rest scroll horizontally; give each pinned column an explicit numeric width so its frozen offset lines up. Sticky positioning is web-only (a no-op on native).

Loading demo…

import { DataTable } from '@plocks/ui'; import type { DataTableColumn } from '@plocks/ui'; type Server = { id: number; host: string; region: string; cpu: string; memory: string; uptime: string; status: 'healthy' | 'degraded' | 'offline'; }; const REGIONS = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-south-1']; const STATUSES: Server['status'][] = ['healthy', 'degraded', 'offline']; const rows: Server[] = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, host: `node-${String(i + 1).padStart(2, '0')}.cluster.internal`, region: REGIONS[i % REGIONS.length], cpu: `${((i * 7) % 90) + 5}%`, memory: `${((i * 13) % 80) + 10}%`, uptime: `${(i % 30) + 1}d`, status: STATUSES[i % STATUSES.length], })); const columns: DataTableColumn<Server>[] = [ // Pinned left, so the host stays visible while the rest scroll horizontally. { key: 'host', header: 'Host', accessor: 'host', sticky: 'left', width: 240, sortable: true }, { key: 'region', header: 'Region', accessor: 'region', width: 160, sortable: true }, { key: 'cpu', header: 'CPU', accessor: 'cpu', width: 120, align: 'right', sortable: true }, { key: 'memory', header: 'Memory', accessor: 'memory', width: 120, align: 'right', sortable: true }, { key: 'uptime', header: 'Uptime', accessor: 'uptime', width: 120, align: 'right' }, { key: 'status', header: 'Status', accessor: 'status', sticky: 'right', width: 140, sortable: true }, ]; export function Demo() { return ( <DataTable data={rows} columns={columns} getRowId={(row) => row.id} h={320} fullWidth={false} searchable={false} /> ); }

Server-side pagination

Set manualPagination when the data comes from a paginated API. The data prop is treated as the already-fetched current page — the table does no client-side slicing, filtering, or sorting — and pagination.total drives the page count and "X-Y of N" summary. The sort, filter, search, and page controls still fire their callbacks (onSortChange, onFilterChange, onSearchChange, onPaginationChange) so you can refetch. Pair it with loading to show the skeleton during each fetch.

Loading demo…

import { useEffect, useState } from 'react'; import { DataTable } from '@plocks/ui'; import type { DataTableColumn, DataTablePagination, DataTableSort } from '@plocks/ui'; type Order = { id: number; customer: string; product: string; amount: number; }; // Pretend this table lives on a server; the component only ever sees one page. const DB: Order[] = Array.from({ length: 137 }, (_, i) => ({ id: i + 1, customer: `Customer ${String(i + 1).padStart(3, '0')}`, product: ['Starter', 'Pro', 'Team', 'Enterprise'][i % 4], amount: Math.round(((i * 37) % 900) + 100), })); // Simulate an API endpoint: GET /orders?page&pageSize&sort function fetchOrders( page: number, pageSize: number, sort?: DataTableSort ): Promise<{ rows: Order[]; total: number }> { return new Promise((resolve) => { setTimeout(() => { const sorted = [...DB]; if (sort?.direction) { sorted.sort((a, b) => { const av = a[sort.column as keyof Order]; const bv = b[sort.column as keyof Order]; const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv)); return sort.direction === 'desc' ? -cmp : cmp; }); } const start = (page - 1) * pageSize; resolve({ rows: sorted.slice(start, start + pageSize), total: DB.length }); }, 500); }); } const columns: DataTableColumn<Order>[] = [ { key: 'id', header: 'Order', accessor: 'id', sortable: true, dataType: 'number' }, { key: 'customer', header: 'Customer', accessor: 'customer', sortable: true }, { key: 'product', header: 'Plan', accessor: 'product', sortable: true }, { key: 'amount', header: 'Amount', accessor: 'amount', sortable: true, dataType: 'currency' }, ]; export function Demo() { const [rows, setRows] = useState<Order[]>([]); const [loading, setLoading] = useState(true); const [sortBy, setSortBy] = useState<DataTableSort[]>([]); const [pagination, setPagination] = useState<DataTablePagination>({ page: 1, pageSize: 10, total: 0 }); // Refetch whenever the page, page size, or sort changes. useEffect(() => { let cancelled = false; setLoading(true); fetchOrders(pagination.page, pagination.pageSize, sortBy[0]).then((res) => { if (cancelled) return; setRows(res.rows); setPagination((p) => (p.total === res.total ? p : { ...p, total: res.total })); setLoading(false); }); return () => { cancelled = true; }; }, [pagination.page, pagination.pageSize, sortBy]); return ( <DataTable data={rows} columns={columns} loading={loading} manualPagination pagination={pagination} onPaginationChange={setPagination} sortBy={sortBy} onSortChange={setSortBy} getRowId={(row) => row.id} searchable={false} /> ); }
Scan to open this page on your phoneplocks.dev/ui/DataTable