DataTable
Browse, filter and manage structured data.
Installation
DataTable accepts data and columns for sorting and pagination, or your own TanStack Table instance for filtering, selection, expansion and column controls. Install the peer dependency before using it:
npm install @tanstack/react-table@^8.21.3
import { DataTable } from '@ifhover/leaf-ui/table';
import type { DataTableProps, DataTableColumnMeta } from '@ifhover/leaf-ui/table';
The table subpath loads its styles automatically. Import DataTable and its data types from @ifhover/leaf-ui/table; DataTable is not exported from the library root.
Basic usage
import { Tag } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
const people = [
{ id: '1', name: 'Alex Morgan', email: 'alex@example.com', role: 'Design', active: true },
{ id: '2', name: 'Sam Taylor', email: 'sam@example.com', role: 'Engineering', active: true },
{ id: '3', name: 'Jordan Lee', email: 'jordan@example.com', role: 'Product', active: false },
];
export function TableBasic() {
return (
<DataTable
data={people}
columns={[
{ accessorKey: 'name', header: 'Member' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Team' },
{
accessorKey: 'active',
header: 'Status',
cell: ({ row }) => (
<Tag color={row.original.active ? 'success' : 'default'}>
{row.original.active ? ('Active') : 'Invited'}
</Tag>
),
},
]}
rowKey="id"
aria-label={'Team members'}
minWidth={560}
pagination={false}
/>
);
}
The data + columns + rowKey mode creates one TanStack instance and uses the same renderer as the advanced mode. rowKey must identify a stable, unique string/number field or return a string. Local sorting, filtering, pagination and expansion are configured by default. Use options for initial state, controlled callbacks or server configuration. pagination={false} displays all rows without internally truncating the data.
These modes are mutually exclusive: when supplying table, do not also pass data, columns, rowKey or options. Prefer your own useReactTable instance for advanced grouping or query state.
Borders and striped rows
DataTable defaults to no outer frame or column dividers, with striped rows enabled. Borderless tables have square corners and no tinted header background, with a 1px divider below the header. Enable bordered for a rounded frame, white header background and column dividers (following the dark surface in dark mode). Headers are bold and slightly lighter than body text in both modes, with sorting icons immediately after the label. Stripes start on the first body row with a faint background and hide row dividers; set striped={false} to show fine dividers between rows. Both ordinary and striped rows show subtle primary-color feedback on hover.
import { Checkbox, Space } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { useState } from 'react';
const people = [
{ name: 'Alex Morgan', team: 'Design' },
{ name: 'Sam Taylor', team: 'Engineering' },
{ name: 'Jordan Lee', team: 'Product' },
];
export function TableAppearance() {
const [bordered, setBordered] = useState(false);
const [striped, setStriped] = useState(true);
return (
<Space direction="vertical" align="stretch">
<Space wrap>
<Checkbox checked={bordered} onChange={(event) => setBordered(event.target.checked)}>
{'Borders'}
</Checkbox>
<Checkbox checked={striped} onChange={(event) => setStriped(event.target.checked)}>
{'Striped rows'}
</Checkbox>
</Space>
<DataTable
data={people}
columns={[
{ accessorKey: 'name', header: 'Member' },
{ accessorKey: 'team', header: 'Team' },
]}
rowKey="name"
pagination={false}
aria-label={'Table appearance'}
bordered={bordered}
striped={striped}
/>
</Space>
);
}
Sorting, filtering, selection and pagination
import { Avatar, Tag } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { type ColumnDef, type ColumnFiltersState, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, type RowSelectionState, type SortingState, useReactTable } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
export interface Member {
id: string;
name: string;
email: string;
team: string;
projects: number;
active: boolean;
}
export const members: Member[] = Array.from({ length: 26 }, (_, index) => ({
id: `member-${index + 1}`,
name:
['Alex Morgan', 'Sam Taylor', 'Jordan Lee', 'Riley Chen', 'Casey Wilson', 'Jamie Park'][
index % 6
] ?? '',
email: `member${index + 1}@example.com`,
team: ['Design', 'Engineering', 'Product'][index % 3] ?? '',
projects: ((index * 7) % 18) + 1,
active: index % 4 !== 2,
}));
export function useMemberColumns(english = false) {
return useMemo<ColumnDef<Member>[]>(
() => [
{
accessorKey: 'name',
header: english ? 'Member' : '成员',
size: 230,
minSize: 160,
maxSize: 400,
enableHiding: false,
cell: ({ row }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size={30}>{row.original.name.slice(0, 1)}</Avatar>
<strong>{row.original.name}</strong>
</div>
),
},
{
accessorKey: 'email',
header: english ? 'Email' : '邮箱',
size: 250,
minSize: 180,
maxSize: 400,
},
{
accessorKey: 'team',
header: english ? 'Team' : '团队',
size: 150,
minSize: 100,
maxSize: 240,
},
{
accessorKey: 'projects',
header: english ? 'Projects' : '项目数',
size: 110,
minSize: 90,
maxSize: 160,
meta: { align: 'end' },
},
{
accessorKey: 'active',
header: english ? 'Status' : '状态',
size: 110,
minSize: 100,
maxSize: 160,
cell: ({ getValue }) => (
<Tag color={getValue() ? 'success' : 'default'}>
{getValue() ? (english ? 'Active' : '活跃') : english ? 'Invited' : '已邀请'}
</Tag>
),
},
],
[english],
);
}
export function TableData() {
const columns = useMemberColumns(true);
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 5 });
const table = useReactTable({
data: members,
columns,
getRowId: (member) => member.id,
state: { sorting, rowSelection, columnFilters, pagination },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: () => true,
enableRowSelection: (row) => row.original.active,
});
return (
<DataTable
table={table}
aria-label={'Team directory'}
selection="multiple"
getRowLabel={(row) => `${row.original.name} (${row.original.id})`}
renderExpandedRow={(row) => (
<p style={{ margin: 0 }}>
{'Contact'}: {row.original.email} · {'Team'}
: {row.original.team}
</p>
)}
>
<DataTable.Toolbar>
<DataTable.Search columnId="name" />
<span style={{ marginInlineStart: 'auto', color: 'var(--leaf-color-text-muted)' }}>
{Object.values(rowSelection).filter(Boolean).length} {'selected'}
</span>
<DataTable.ColumnVisibility />
</DataTable.Toolbar>
<DataTable.Content />
<DataTable.Footer>
<DataTable.Pagination
pageSizeOptions={[5, 10, 20]}
showTotal={(total) => (`${total} members`)}
/>
</DataTable.Footer>
</DataTable>
);
}
The provided instance is the only state owner. Leaf UI does not sort or paginate the data again, or create a second selection/filter store. Configure the row models required by your application:
Provide a stable getRowId; do not use an index after sorting or pagination. Select-all defaults to selectable rows on the current page. selectAllMode="all" selects loaded, filtered records, not unloaded server records. Selection keys persist across pages in TanStack state; for server data use these keys rather than assuming getSelectedRowModel() contains unloaded records.
DataTable.Search updates the global filter unless columnId is specified. ColumnVisibility respects enableHiding: false. Shift-click a header for multi-sort; aria-sort identifies the primary sort column.
Sticky headers, pinned columns and resizing
import { Avatar, Button, ConfigProvider, Tag } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { type ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useMemo } from 'react';
export interface Member {
id: string;
name: string;
email: string;
team: string;
projects: number;
active: boolean;
}
export const members: Member[] = Array.from({ length: 26 }, (_, index) => ({
id: `member-${index + 1}`,
name:
['Alex Morgan', 'Sam Taylor', 'Jordan Lee', 'Riley Chen', 'Casey Wilson', 'Jamie Park'][
index % 6
] ?? '',
email: `member${index + 1}@example.com`,
team: ['Design', 'Engineering', 'Product'][index % 3] ?? '',
projects: ((index * 7) % 18) + 1,
active: index % 4 !== 2,
}));
export function useMemberColumns(english = false) {
return useMemo<ColumnDef<Member>[]>(
() => [
{
accessorKey: 'name',
header: english ? 'Member' : '成员',
size: 230,
minSize: 160,
maxSize: 400,
enableHiding: false,
cell: ({ row }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size={30}>{row.original.name.slice(0, 1)}</Avatar>
<strong>{row.original.name}</strong>
</div>
),
},
{
accessorKey: 'email',
header: english ? 'Email' : '邮箱',
size: 250,
minSize: 180,
maxSize: 400,
},
{
accessorKey: 'team',
header: english ? 'Team' : '团队',
size: 150,
minSize: 100,
maxSize: 240,
},
{
accessorKey: 'projects',
header: english ? 'Projects' : '项目数',
size: 110,
minSize: 90,
maxSize: 160,
meta: { align: 'end' },
},
{
accessorKey: 'active',
header: english ? 'Status' : '状态',
size: 110,
minSize: 100,
maxSize: 160,
cell: ({ getValue }) => (
<Tag color={getValue() ? 'success' : 'default'}>
{getValue() ? (english ? 'Active' : '活跃') : english ? 'Invited' : '已邀请'}
</Tag>
),
},
],
[english],
);
}
export function TableColumns() {
const columns = useMemberColumns(true);
const table = useReactTable({
data: members,
columns,
getRowId: (member) => member.id,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
initialState: { columnPinning: { left: ['name'], right: ['active'] } },
});
return (
<ConfigProvider density="compact">
<DataTable
table={table}
aria-label={'Pinned and resizable columns'}
stickyHeader
maxHeight={300}
resizable
pagination={false}
>
<DataTable.Toolbar>
<span style={{ color: 'var(--leaf-color-text-muted)' }}>
{'Drag a header edge or focus it and use the arrow keys.'}
</span>
<Button
style={{ marginInlineStart: 'auto' }}
size="sm"
variant="soft"
onClick={() => table.resetColumnSizing()}
>
{'Reset widths'}
</Button>
</DataTable.Toolbar>
<DataTable.Content />
</DataTable>
</ConfigProvider>
);
}
Control pinning through columnPinning, order through columnOrder, and widths through each column's size, minSize and maxSize. Leaf interprets left/right as logical start/end sides. For RTL also configure TanStack columnResizeDirection: 'rtl'.
Tables fill their container and scroll horizontally only when the columns need more space. Headers are sticky by default. Set maxHeight to scroll overflowing rows within the body. Overlay tracks use Scrollbar; the vertical track starts below the header. When content fits or reaches an edge, wheel scrolling continues to the page.
Drag a resize handle or focus it and press Left/Right. Shift increases the step; Home/End moves to its bounds; double-click resets its width. Set a sensible maxSize. ConfigProvider density and theme.components.Table customize spacing, radius, typography, foreground, background and shadow.
Grouping and aggregation
import { Tag } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { type ColumnDef, getCoreRowModel, getExpandedRowModel, getGroupedRowModel, useReactTable } from '@tanstack/react-table';
import { useMemo } from 'react';
import { type Member, members } from './data';
const groupedMembers = members.slice(0, 8);
export function TableGrouped() {
const columns = useMemo<ColumnDef<Member>[]>(
() => [
{ accessorKey: 'team', header: 'Team', size: 150 },
{ accessorKey: 'name', header: 'Member', size: 240 },
{
accessorKey: 'projects',
header: 'Projects',
aggregationFn: 'sum',
meta: { align: 'end' },
aggregatedCell: ({ getValue }) => (
<Tag>
{'Total'}: {String(getValue())}
</Tag>
),
},
],
[true],
);
const table = useReactTable({
data: groupedMembers,
columns,
getRowId: (member) => member.id,
getCoreRowModel: getCoreRowModel(),
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
initialState: { grouping: ['team'], expanded: true },
});
return (
<DataTable
table={table}
aria-label={'Members grouped by team'}
pagination={false}
/>
);
}
Ordinary grouped tables transition row height when expanding or collapsing, preserving child content and stripe positions. Regional motion settings and system reduced motion disable the transition; large or virtual tables also update immediately.
Editable cells
import { Input } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { type ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { type Member, members } from './data';
export function TableEditable() {
const [data, setData] = useState(members.slice(0, 3));
const columns = useMemo<ColumnDef<Member>[]>(
() => [
{
accessorKey: 'name',
header: 'Member',
size: 240,
cell: ({ row }) => (
<Input
aria-label={`${'Name'}: ${row.original.id}`}
value={row.original.name}
onChange={(event) => {
const name = event.currentTarget.value;
setData((previous) =>
previous.map((member) =>
member.id === row.original.id ? { ...member, name } : member,
),
);
}}
/>
),
},
{ accessorKey: 'email', header: 'Email', size: 260 },
{ accessorKey: 'team', header: 'Team', size: 140 },
],
[true],
);
const table = useReactTable({
data,
columns,
getRowId: (member) => member.id,
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
aria-label={'Editable member names'}
pagination={false}
/>
);
}
Render Input, Select or other controls through cell. Keep data, submission and validation in the application; there is no second form store. Opened detail content stays mounted when collapsed, preserving input state; unopened detail content is not mounted.
Row and column reordering
Use the handles to reorder rows or columns.
import { Avatar, Space, Tag } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import type { ColumnDef } from '@tanstack/react-table';
import { useMemo, useState } from 'react';
const members = [
{ id: 'alex', name: 'Alex Morgan', team: 'Design', status: 'active' },
{ id: 'sam', name: 'Sam Taylor', team: 'Engineering', status: 'active' },
{ id: 'jordan', name: 'Jordan Lee', team: 'Product', status: 'invited' },
];
type Member = (typeof members)[number];
export function TableReordering() {
const [data, setData] = useState(members);
const columns = useMemo<ColumnDef<Member>[]>(
() => [
{
accessorKey: 'name',
header: 'Member',
size: 220,
cell: ({ row }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Avatar size="sm">{row.original.name[0]}</Avatar>
<strong>{row.original.name}</strong>
</div>
),
},
{ accessorKey: 'team', header: 'Team' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => (
<Tag color={getValue() === 'active' ? 'success' : 'default'}>
{getValue() === 'active'
? 'Active'
: 'Invited'}
</Tag>
),
},
],
[true],
);
return (
<Space direction="vertical" align="stretch">
<p>
{'Use the handles to reorder rows or columns.'}
</p>
<DataTable
data={data}
columns={columns}
rowKey="id"
caption={'Team priorities'}
aria-label={'Reorderable team'}
getRowLabel={(row) => row.original.name}
options={{ enableSorting: false }}
pagination={false}
columnReordering
onRowReorder={({ rows }) => setData(rows.map((row) => row.original))}
/>
</Space>
);
}
Handles support pointer, touch and keyboard interaction. Focus a handle, press Space to start, use arrow keys to move, Space to drop or Escape to cancel. onRowReorder returns the reordered current-page rows and stable identifiers; the application updates its data or submits the server change. Column dragging writes directly to TanStack's columnOrder.
Row handles are only shown for flat, nonvirtual tables without active sorting, filtering, grouping or expansion, so display order cannot conflict with business order. Column handles support single-level headers and stay within the same pinned region. For grouped headers, set columnOrder explicitly. Reordering preserves row selection and mounted editor state.
Large data
import { DataTable } from '@ifhover/leaf-ui/table';
import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table';
import { type Member, members, useMemberColumns } from './data';
const manyMembers: Member[] = Array.from({ length: 10000 }, (_, index) => ({
...(members[index % members.length] as Member),
id: String(index),
name: `Member ${String(index + 1).padStart(5, '0')}`,
}));
export function TableVirtual() {
const columns = useMemberColumns(true);
const table = useReactTable({
data: manyMembers,
columns,
getRowId: (member) => member.id,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DataTable
table={table}
aria-label={'10000 members'}
virtual={{ estimateRowHeight: 55, overscan: 6 }}
stickyHeader
maxHeight={320}
pagination={false}
/>
);
}
Row virtualization is explicit and off by default. Set maxHeight and an appropriate estimated height; dynamic heights are measured and a focused row is retained when scrolling. SSR renders an estimated window before client measurement.
This is not a spreadsheet DataGrid: it does not add arrow-key cell navigation, column virtualization or automatic mobile cards. Tables retain native semantics and horizontal scrolling. For screen-reader users who need all data in the accessibility tree, disable virtualization and use pagination.
Server data
import { Button } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { getCoreRowModel, type PaginationState, useReactTable } from '@tanstack/react-table';
import { useEffect, useState } from 'react';
import { type Member, members, useMemberColumns } from './data';
export function TableServer() {
const columns = useMemberColumns(true);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 5 });
const [data, setData] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [request, setRequest] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(false);
const timer = setTimeout(
() => {
if (controller.signal.aborted) return;
setData(
members.slice(
pagination.pageIndex * pagination.pageSize,
(pagination.pageIndex + 1) * pagination.pageSize,
),
);
setLoading(false);
},
request > 0 ? 350 : 500,
);
return () => {
controller.abort();
clearTimeout(timer);
};
}, [pagination, request]);
const table = useReactTable({
data,
columns,
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
manualSorting: true,
enableSorting: false,
rowCount: members.length,
getRowId: (member) => member.id,
getCoreRowModel: getCoreRowModel(),
});
return (
<DataTable
table={table}
aria-label={'Remote members'}
loading={loading}
error={error ? ('Unable to load members') : undefined}
onRetry={() => setRequest((value) => value + 1)}
>
<DataTable.Toolbar>
<Button
variant="outline"
disabled={loading}
onClick={() => setRequest((value) => value + 1)}
>
{'Refresh'}
</Button>
<Button variant="soft" disabled={loading} onClick={() => setError(true)}>
{'Simulate error'}
</Button>
</DataTable.Toolbar>
<DataTable.Content />
<DataTable.Footer>
<DataTable.Pagination pageSizeOptions={[5, 10, 20]} />
</DataTable.Footer>
</DataTable>
);
}
Use manualPagination with rowCount, and manualSorting / manualFiltering for corresponding server features. Use controlled TanStack state as request parameters and cancel stale requests. Set pageCount: -1 for unknown totals; the footer then offers only Previous/Next. Supply <DataTable.Pagination hasNextPage={hasMore} /> to prevent advancing past the available records.
Refreshing keeps existing rows, adds a loading overlay and prevents duplicate interactions. First load, empty data and errors have separate state rows. DataTable automatically renders content and pagination unless pagination is false. Providing children replaces the default layout; explicitly arrange Toolbar, Content and Footer in that case.
Empty, loading and error
import { Button, Space } from '@ifhover/leaf-ui';
import { DataTable } from '@ifhover/leaf-ui/table';
import { useState } from 'react';
const people = [
{ id: '1', name: 'Alex Morgan', email: 'alex@example.com', role: 'Design', active: true },
{ id: '2', name: 'Sam Taylor', email: 'sam@example.com', role: 'Engineering', active: true },
{ id: '3', name: 'Jordan Lee', email: 'jordan@example.com', role: 'Product', active: false },
];
const emptyPeople: typeof people = [];
export function TableStates() {
const [state, setState] = useState<'empty' | 'loading' | 'error'>('empty');
return (
<Space direction="vertical" align="stretch">
<Space wrap>
<Button variant={state === 'empty' ? 'outline' : 'soft'} onClick={() => setState('empty')}>
{'Empty'}
</Button>
<Button
variant={state === 'loading' ? 'outline' : 'soft'}
onClick={() => setState('loading')}
>
{'Loading'}
</Button>
<Button variant={state === 'error' ? 'outline' : 'soft'} onClick={() => setState('error')}>
{'Error'}
</Button>
</Space>
<DataTable
data={emptyPeople}
columns={[
{ accessorKey: 'name', header: 'Member' },
{ accessorKey: 'role', header: 'Team' },
]}
rowKey="id"
aria-label={'Data states'}
pagination={false}
loading={state === 'loading'}
error={state === 'error' ? ('Unable to load members') : null}
onRetry={() => setState('empty')}
/>
</Space>
);
}
DataTable API
This table describes the instance mode. For the data and columns mode, see DataTableData API.
aria-label/aria-labelledby name the native table; other div attributes apply to the wrapper.
DataTableRow API
DataTableRowProps configures native tr attributes and events through getRowProps.
DataTableCell API
DataTableCellProps configures native td attributes, spans and events through getCellProps.
DataTableSearch API
Other Input attributes are supported; value and onChange belong to the instance.
DataTableColumnVisibility API
Supports Pagination display options. Total, current, pageSize and onChange belong to the instance; showSizeChanger defaults to true. Unknown totals use Previous/Next only.
DataTableContent API
DataTable.Content accepts native div attributes, including className, style and events. Configure table layout, selection, expansion and loading on DataTable; use tableRef for the table DOM ref. The exported type is DataTableContentProps.
Use this exported type to assemble the following optional properties on TanStack column meta. Supply label for functional headers.
DataTableVirtualOptions
Types and composition
import type { DataTableProps, DataTableDataProps, DataTableRowProps, DataTableCellProps, DataTableSearchProps, DataTableColumnVisibilityProps, DataTablePaginationProps, DataTableContentProps, DataTableColumnMeta, DataTableVirtualOptions, DataTableRowReorderInfo } from '@ifhover/leaf-ui/table';
import type { ColumnDef, Table as TanStackTable, Row, Cell } from '@tanstack/react-table';
DataTable.Toolbar/Footer/Content accept div attributes. Use TanStack's own column definitions, controlled state, row selection and pinning rather than a second Leaf column model.
DataTableData API
Additional convenience-mode properties are listed below. Presentation, selection, expansion and scrolling options match DataTable API; table is not accepted. The exported type is DataTableDataProps<TData>.
DataTableRowReorderInfo
DataTablePresentation API
ConfigProvider's components.DataTable uses these row-independent props. Import DataTablePresentationProps from @ifhover/leaf-ui to type reusable defaults. Configuring defaults alone does not require TanStack Table; using DataTable still requires it.