For AI agents: the complete documentation index is available at https://ifhover.github.io/leaf-ui/en/llms.txt, the full documentation bundle is available at https://ifhover.github.io/leaf-ui/en/llms-full.txt, and this page is available as Markdown at https://ifhover.github.io/leaf-ui/en/components/table.md.

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

Alex Morganalex@example.comDesignActive
Sam Taylorsam@example.comEngineeringActive
Jordan Leejordan@example.comProductInvited
basic.tsx
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.

Alex MorganDesign
Sam TaylorEngineering
Jordan LeeProduct
appearance.tsx
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

0 selected
Expand rows
Alex Morgan
member1@example.comDesign1Active
Sam Taylor
member2@example.comEngineering8Active
Jordan Lee
member3@example.comProduct15Invited
Riley Chen
member4@example.comDesign4Active
Casey Wilson
member5@example.comEngineering11Active
data.tsx
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:

FeatureTanStack configuration
DatagetCoreRowModel()
Client sortinggetSortedRowModel()
Client filteringgetFilteredRowModel()
Client paginationgetPaginationRowModel()
Tree/group expansiongetExpandedRowModel() and getSubRows for trees
Detail rowsgetRowCanExpand and DataTable renderExpandedRow
Grouping and totalsgetGroupedRowModel(), aggregationFn, aggregatedCell

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

Drag a header edge or focus it and use the arrow keys.





Alex Morgan
member1@example.comDesign1Active
Sam Taylor
member2@example.comEngineering8Active
Jordan Lee
member3@example.comProduct15Invited
Riley Chen
member4@example.comDesign4Active
Casey Wilson
member5@example.comEngineering11Active
Jamie Park
member6@example.comProduct18Active
Alex Morgan
member7@example.comDesign7Invited
Sam Taylor
member8@example.comEngineering14Active
Jordan Lee
member9@example.comProduct3Active
Riley Chen
member10@example.comDesign10Active
Casey Wilson
member11@example.comEngineering17Invited
Jamie Park
member12@example.comProduct6Active
Alex Morgan
member13@example.comDesign13Active
Sam Taylor
member14@example.comEngineering2Active
Jordan Lee
member15@example.comProduct9Invited
Riley Chen
member16@example.comDesign16Active
Casey Wilson
member17@example.comEngineering5Active
Jamie Park
member18@example.comProduct12Active
Alex Morgan
member19@example.comDesign1Invited
Sam Taylor
member20@example.comEngineering8Active
Jordan Lee
member21@example.comProduct15Active
Riley Chen
member22@example.comDesign4Active
Casey Wilson
member23@example.comEngineering11Invited
Jamie Park
member24@example.comProduct18Active
Alex Morgan
member25@example.comDesign7Active
Sam Taylor
member26@example.comEngineering14Active
data.tsx
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

Expand rows
Design (3)Total: 12
Alex Morgan
1
Riley Chen
4
Alex Morgan
7
Engineering (3)Total: 33
Sam Taylor
8
Casey Wilson
11
Sam Taylor
14
Product (2)Total: 33
Jordan Lee
15
Jamie Park
18
advanced.tsx
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

member1@example.comDesign
member2@example.comEngineering
member3@example.comProduct
advanced.tsx
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.

Team priorities
Reorder rows
Member
Team
Status
Alex Morgan
DesignActive
Sam Taylor
EngineeringActive
Jordan Lee
ProductInvited
reordering.tsx
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

Member 00001
member1@example.comDesign1Active
Member 00002
member2@example.comEngineering8Active
Member 00003
member3@example.comProduct15Invited
Member 00004
member4@example.comDesign4Active
Member 00005
member5@example.comEngineering11Active
Member 00006
member6@example.comProduct18Active
Member 00007
member7@example.comDesign7Invited
Member 00008
member8@example.comEngineering14Active
Member 00009
member9@example.comProduct3Active
Member 00010
member10@example.comDesign10Active
Member 00011
member11@example.comEngineering17Invited
Member 00012
member12@example.comProduct6Active
advanced.tsx
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

Member
Email
Team
Projects
Status
Loading
advanced.tsx
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

No data
basic.tsx
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.

PropertyTypeDefaultDescription
childrenReactNode—Replace the default Content/Pagination layout.
captionReactNode—Table caption.
size'sm' | 'md' | 'lg''md'Presentation size.
borderedbooleanfalseShow the outer frame and column dividers. Borderless headers have a 1px bottom divider.
stripedbooleantrueAlternate row backgrounds and hide row dividers. Disable to show fine dividers between rows.
stickyHeaderbooleantrueKeep headers at the top of the scroll region.
maxHeightCSSProperties['maxHeight']—Maximum scroll height; virtual mode defaults to 400 when omitted.
minWidthCSSProperties['minWidth']—Minimum table width; defaults to visible column sizes.
loadingbooleanfalseShow a loading state.
emptyContentReactNode—Empty content.
errorReactNode—Error content; null, undefined and false do not show an error.
onRetry() => void—Retry action.
selectionfalse | 'single' | 'multiple'falseNone, single or multiple row selection.
selectAllMode'page' | 'all''page'Select the page or all loaded selectable rows.
resizablebooleanfalseShow resize handles for eligible columns.
columnReorderingbooleanfalseShow drag handles for single-level headers.
virtualboolean | DataTableVirtualOptionsfalseExplicit row virtualization.
paginationbooleantruePagination in the default layout.
tableRefRef<HTMLTableElement>—Native table DOM ref.
tableTanStackTable<TData>—Required. Instance created by useReactTable; the sole data and state owner.
getRowLabel(row: Row<TData>) => string—Accessible row-action name; defaults to row.id.
renderExpandedRow(row: Row<TData>) => ReactNode—Leaf-row details; configure getRowCanExpand.
getRowProps(row: Row<TData>) => DataTableRowProps—Custom row attributes/events.
getCellProps(cell: Cell<TData, unknown>) => DataTableCellProps—Cell attributes, styles or spans; a zero colSpan / rowSpan omits the cell.
onRowReorder(info: DataTableRowReorderInfo<TData>) => void—Show row handles and return reordered current-page rows; the application updates the data.

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.

PropertyTypeDefaultDescription
selectedboolean—Apply the selected row appearance.

DataTableCell API

DataTableCellProps configures native td attributes, spans and events through getCellProps.

PropertyTypeDefaultDescription
align'start' | 'center' | 'end''start'Cell content alignment.
pinned'start' | 'end'—Pin to the logical start or end.
pinOffsetnumber0Pixel offset from that side.
pinEdgeboolean—Show a subtle pinned-edge shadow.

DataTableSearch API

PropertyTypeDefaultDescription
size'sm' | 'md' | 'lg'—Inherit DataTable size by default.
status'error' | 'warning'—Input validation status.
prefixReactNode—Leading content; defaults to a search icon.
suffixReactNode—Trailing content.
allowClearbooleantrueShow a clear action.
onClear() => void—Called when the search is cleared.
showCountboolean | ((value: string, maxLength?: number) => ReactNode)—Display or customize the input count.
onPressEnterKeyboardEventHandler<HTMLInputElement>—Called on Enter.
columnIdstring—Filter one column; omit for global search.

Other Input attributes are supported; value and onChange belong to the instance.

DataTableColumnVisibility API

PropertyTypeDefaultDescription
childrenReactNode—Custom button text.
labelstring—Accessible panel name.

DataTablePagination API

PropertyTypeDefaultDescription
showSizeChangerbooleantrueShow the page-size selector.
pageSizeOptionsreadonly number[]—Available page sizes.
showQuickJumperbooleanfalseCommit a quick jump on Enter or when leaving the pagination region.
showTotalboolean | ((total: number, range: readonly [number, number]) => ReactNode)falsetrue shows a localized total; pass a function to customize the total and visible range.
simpleboolean—Use compact pagination.
disabledboolean—Disable interaction.
hideOnSinglePageboolean—Hide when only one page exists.
size'sm' | 'md' | 'lg'—Inherit DataTable size by default.
variant'outline' | 'ghost''outline'Pagination button appearance: outlined or borderless.
hasNextPageboolean—Control next-page availability when the total is unknown.

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

PropertyTypeDefaultDescription
classNamestring—Custom class for the content region.
styleCSSProperties—Inline styles for the content region.

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.

DataTableColumnMeta

Use this exported type to assemble the following optional properties on TanStack column meta. Supply label for functional headers.

PropertyTypeDefaultDescription
labelstring—Plain-text column name used by controls.
align'start' | 'center' | 'end''start'Header and cell alignment.
cellClassNamestring—Cell class.
headerClassNamestring—Header class.

DataTableVirtualOptions

PropertyTypeDefaultDescription
estimateRowHeightnumber48Estimated height before measuring.
overscannumber5Additional rows before/after the viewport.

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>.

PropertyTypeDefaultDescription
datareadonly TData[]—Required. Data to display; provide a new array when updating.
columnsColumnDef<TData>[]—Required. Native TanStack column definitions.
rowKeykeyof TData | ((row: TData, index: number) => string)—Required. A stable unique key field or a function returning a string identifier.
optionsOmit<TableOptions<TData>, 'data' | 'columns' | 'getCoreRowModel' | 'getRowId'>—Customize TanStack state, callbacks and server configuration.

DataTableRowReorderInfo

PropertyTypeDefaultDescription
rowsreadonly Row<TData>[]—Required. Reordered current-page rows; read business data from row.original.
fromIndexnumber—Required. Original zero-based current-page index.
toIndexnumber—Required. Destination zero-based current-page index.
activeRowRow<TData>—Required. The moved row, including its stable row.id.
overRowRow<TData>—Required. The target row, including its stable row.id.

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.

PropertyTypeDefaultDescription
childrenReactNode—Content, or child nodes for hierarchical records.
captionReactNode—Configure caption.
size'sm' | 'md' | 'lg'—Presentation size.
borderedboolean—Configure bordered.
stripedboolean—Configure striped.
stickyHeaderboolean—Configure sticky header.
maxHeightCSSProperties['maxHeight']—Configure max height.
minWidthCSSProperties['minWidth']—Configure min width.
loadingboolean—Show a loading state.
emptyContentReactNode—Content for an empty collection.
errorReactNode—Configure error.
onRetry() => void—Configure on retry.
selectionfalse | 'single' | 'multiple'—Configure selection.
selectAllMode'page' | 'all'—Configure select all mode.
resizableboolean—Allow resizing by dragging or arrow keys.
columnReorderingboolean—Configure column reordering.
virtualboolean | DataTableVirtualOptions—Enable row virtualization; disabled by default.
paginationboolean—Configure pagination.
tableRefRef<HTMLTableElement>—Configure table ref.