Agents (llms.txt)
Octocat

Table

Semantic table primitive. Compose with TanStack Table for sorting, selection, filtering, and pagination.

Recent invoices
InvoiceClientStatusAmount
INV-001Acme CorpPaid$1,250.00
INV-002GlobexPending$480.00
INV-003InitechPaid$3,200.00
INV-004UmbrellaOverdue$920.00
INV-005SoylentPending$1,675.00
Total$7,525.00
import { Table } from '@/components/table';

export const meta = { layout: 'padded' } as const;

const invoices = [
  { id: 'INV-001', client: 'Acme Corp', status: 'Paid', amount: 1250 },
  { id: 'INV-002', client: 'Globex', status: 'Pending', amount: 480 },
  { id: 'INV-003', client: 'Initech', status: 'Paid', amount: 3200 },
  { id: 'INV-004', client: 'Umbrella', status: 'Overdue', amount: 920 },
  { id: 'INV-005', client: 'Soylent', status: 'Pending', amount: 1675 },
];

const formatAmount = (value: number) =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(value);

export default function TablePreview() {
  const total = invoices.reduce((sum, i) => sum + i.amount, 0);

  return (
    <Table>
      <Table.Caption>Recent invoices</Table.Caption>
      <Table.Header>
        <Table.Row>
          <Table.Head>Invoice</Table.Head>
          <Table.Head>Client</Table.Head>
          <Table.Head>Status</Table.Head>
          <Table.Head align="end">Amount</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {invoices.map((invoice) => (
          <Table.Row key={invoice.id}>
            <Table.Cell className="font-medium">{invoice.id}</Table.Cell>
            <Table.Cell>{invoice.client}</Table.Cell>
            <Table.Cell>{invoice.status}</Table.Cell>
            <Table.Cell align="end">{formatAmount(invoice.amount)}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
      <Table.Footer>
        <Table.Row>
          <Table.Cell colSpan={3}>Total</Table.Cell>
          <Table.Cell align="end">{formatAmount(total)}</Table.Cell>
        </Table.Row>
      </Table.Footer>
    </Table>
  );
}

Dependencies

Source Code

import {
  CaretDownIcon,
  CaretUpDownIcon,
  CaretUpIcon,
} from '@phosphor-icons/react/dist/ssr';

import { cn, cva } from '@/lib/utils/classnames';

const tableStyle = cva({
  base: [
    'w-full caption-bottom border-collapse text-sm',
    '[--table-cell-px:--spacing(4)] [--table-cell-py:--spacing(3)]',
  ],
  variants: {
    density: {
      sm: '[--table-cell-py:--spacing(2)]',
      md: '[--table-cell-py:--spacing(3)]',
    },
  },
  defaultVariants: {
    density: 'md',
  },
});

interface TableProps extends React.ComponentPropsWithRef<'table'> {
  density?: 'sm' | 'md';
}

const Table = ({ ref, className, density, ...props }: TableProps) => {
  return (
    <div className="relative w-full overflow-x-auto">
      <table
        ref={ref}
        className={cn(tableStyle({ density }), className)}
        {...props}
      />
    </div>
  );
};

interface TableHeaderProps extends React.ComponentPropsWithRef<'thead'> {
  /**
   * Pin the header to the top of the nearest scroll container. The container
   * must have a defined height for `position: sticky` to engage.
   */
  sticky?: boolean;
}

const TableHeader = ({
  ref,
  className,
  sticky,
  ...props
}: TableHeaderProps) => {
  return (
    <thead
      ref={ref}
      className={cn(
        '[&_tr]:border-border [&_tr]:border-b',
        sticky && 'sticky top-0 z-10 bg-background',
        className
      )}
      {...props}
    />
  );
};

const TableBody = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'tbody'>) => {
  return (
    <tbody
      ref={ref}
      className={cn(
        '[&_tr:last-child]:border-0 [&_tr]:border-border [&_tr]:border-b',
        className
      )}
      {...props}
    />
  );
};

const TableFooter = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'tfoot'>) => {
  return (
    <tfoot
      ref={ref}
      className={cn(
        'border-border border-t bg-foreground/[0.02] font-medium',
        className
      )}
      {...props}
    />
  );
};

const TableRow = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'tr'>) => {
  return (
    <tr
      ref={ref}
      className={cn(
        'transition-colors',
        'data-selected:bg-foreground/[0.04]',
        'hover:bg-foreground/[0.02]',
        className
      )}
      {...props}
    />
  );
};

const cellAlignStyle = cva({
  variants: {
    align: {
      start: 'text-start',
      center: 'text-center',
      end: 'text-end',
    },
  },
  defaultVariants: {
    align: 'start',
  },
});

interface TableHeadProps
  extends Omit<React.ComponentPropsWithRef<'th'>, 'align'> {
  align?: 'start' | 'center' | 'end';
}

const TableHead = ({ ref, className, align, ...props }: TableHeadProps) => {
  return (
    <th
      ref={ref}
      className={cn(
        'h-10 px-(--table-cell-px) align-middle font-medium text-foreground-secondary',
        'whitespace-nowrap',
        cellAlignStyle({ align }),
        className
      )}
      {...props}
    />
  );
};

interface TableCellProps
  extends Omit<React.ComponentPropsWithRef<'td'>, 'align'> {
  align?: 'start' | 'center' | 'end';
}

const TableCell = ({ ref, className, align, ...props }: TableCellProps) => {
  return (
    <td
      ref={ref}
      className={cn(
        'px-(--table-cell-px) py-(--table-cell-py) align-middle',
        cellAlignStyle({ align }),
        className
      )}
      {...props}
    />
  );
};

const TableCaption = ({
  ref,
  className,
  ...props
}: React.ComponentPropsWithRef<'caption'>) => {
  return (
    <caption
      ref={ref}
      className={cn('mt-4 text-foreground-secondary text-sm', className)}
      {...props}
    />
  );
};

interface TableSortableHeadProps extends Omit<TableHeadProps, 'onClick'> {
  /**
   * Current sort direction for this column. `false` means unsorted.
   */
  sort: 'asc' | 'desc' | false;
  /**
   * Called when the consumer clicks the header. Implement the
   * `unsorted → asc → desc → unsorted` (or whichever) cycle yourself.
   */
  onSort: (event?: React.MouseEvent<HTMLButtonElement>) => void;
}

const TableSortableHead = ({
  className,
  children,
  sort,
  onSort,
  ...props
}: TableSortableHeadProps) => {
  const ariaSort =
    sort === 'asc' ? 'ascending' : sort === 'desc' ? 'descending' : 'none';

  const Icon =
    sort === 'asc'
      ? CaretUpIcon
      : sort === 'desc'
        ? CaretDownIcon
        : CaretUpDownIcon;

  return (
    <TableHead
      aria-sort={ariaSort}
      data-sort={sort || undefined}
      className={cn('p-0', className)}
      {...props}
    >
      <button
        type="button"
        onClick={onSort}
        className={cn(
          'group/sort flex h-10 w-full items-center gap-1.5 px-(--table-cell-px) py-0',
          'cursor-pointer text-start font-medium text-foreground-secondary',
          'transition-colors hover:text-foreground',
          'focus-visible:ring-(length:--ring-width) outline-none ring-ring',
          'data-[align=end]:justify-end data-[align=center]:justify-center'
        )}
        data-align={props.align}
      >
        {children}
        <Icon
          aria-hidden="true"
          className={cn(
            'size-3.5 shrink-0 transition-opacity',
            sort ? 'opacity-100' : 'opacity-40 group-hover/sort:opacity-70'
          )}
        />
      </button>
    </TableHead>
  );
};

const CompoundTable = Object.assign(Table, {
  Header: TableHeader,
  Body: TableBody,
  Footer: TableFooter,
  Row: TableRow,
  Head: TableHead,
  Cell: TableCell,
  Caption: TableCaption,
  SortableHead: TableSortableHead,
});

export { CompoundTable as Table };

Anatomy


          <Table>
  <Table.Caption />
  <Table.Header>
    <Table.Row>
      <Table.Head />
      <Table.SortableHead sort={false} onSort={() => {}} />
    </Table.Row>
  </Table.Header>
  <Table.Body>
    <Table.Row>
      <Table.Cell />
    </Table.Row>
  </Table.Body>
  <Table.Footer />
</Table>
        

The compound parts are purely structural — they map one-to-one onto the underlying HTML table elements (<table>, <thead>, <tbody>, <tfoot>, <tr>, <th>, <td>, <caption>). State for sorting, selection, filtering, and pagination lives with the consumer. For dashboards with non-trivial data, compose with TanStack Table — see the recipe below.

Table wraps the rendered <table> in a relative w-full overflow-x-auto div so wide tables scroll horizontally on narrow viewports without extra ceremony.

API Reference

Table

Extends the table element. Renders inside an overflow-aware wrapper.

Prop Default Type Description
density "md" "sm""md" Vertical padding of body cells.

Table.Header

Extends the thead element.

Prop Default Type Description
sticky - boolean Pin the header to the top of the nearest scroll container. Requires the container to have a defined height for `position: sticky` to engage.

Table.Body

Extends the tbody element. Adds bottom borders between rows.

Table.Footer

Extends the tfoot element. Use for totals or summary rows.

Table.Row

Extends the tr element. Set data-selected on the row to apply the selected style — the data-table recipe wires this up automatically.

Table.Head

Extends the th element.

Prop Default Type Description
align "start" "start""center""end" Text alignment. Use 'end' for numeric columns.

Table.Cell

Extends the td element.

Prop Default Type
align "start" "start""center""end"

Table.Caption

Extends the caption element. Renders below the table by default (caption-side: bottom).

Table.SortableHead

Renders a <th> containing a sort button with caret affordance and aria-sort wiring. State is fully controlled — pass the current direction and handle the cycle yourself.

Prop Default Type Description
sort - "asc""desc"false Current sort direction. `false` means unsorted.
onSort - () => void Called when the header is activated. Implement the unsorted → asc → desc cycle in the parent.
align "start" "start""center""end"

Examples

Basic

A static table with a footer summary.

Recent invoices
InvoiceClientStatusAmount
INV-001Acme CorpPaid$1,250.00
INV-002GlobexPending$480.00
INV-003InitechPaid$3,200.00
INV-004UmbrellaOverdue$920.00
INV-005SoylentPending$1,675.00
Total$7,525.00
import { Table } from '@/components/table';

export const meta = { layout: 'padded' } as const;

const invoices = [
  { id: 'INV-001', client: 'Acme Corp', status: 'Paid', amount: 1250 },
  { id: 'INV-002', client: 'Globex', status: 'Pending', amount: 480 },
  { id: 'INV-003', client: 'Initech', status: 'Paid', amount: 3200 },
  { id: 'INV-004', client: 'Umbrella', status: 'Overdue', amount: 920 },
  { id: 'INV-005', client: 'Soylent', status: 'Pending', amount: 1675 },
];

const formatAmount = (value: number) =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(value);

export default function TablePreview() {
  const total = invoices.reduce((sum, i) => sum + i.amount, 0);

  return (
    <Table>
      <Table.Caption>Recent invoices</Table.Caption>
      <Table.Header>
        <Table.Row>
          <Table.Head>Invoice</Table.Head>
          <Table.Head>Client</Table.Head>
          <Table.Head>Status</Table.Head>
          <Table.Head align="end">Amount</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {invoices.map((invoice) => (
          <Table.Row key={invoice.id}>
            <Table.Cell className="font-medium">{invoice.id}</Table.Cell>
            <Table.Cell>{invoice.client}</Table.Cell>
            <Table.Cell>{invoice.status}</Table.Cell>
            <Table.Cell align="end">{formatAmount(invoice.amount)}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
      <Table.Footer>
        <Table.Row>
          <Table.Cell colSpan={3}>Total</Table.Cell>
          <Table.Cell align="end">{formatAmount(total)}</Table.Cell>
        </Table.Row>
      </Table.Footer>
    </Table>
  );
}

Sortable headers

Local state drives the sort direction. The header cycles asc → desc on each click.

Ana CostaDesigner2024-02-12
Bruno SilvaEngineer2023-08-30
Carla MendesPM2025-01-04
Diogo PintoEngineer2022-11-19
Elisa FariaDesigner2024-09-22
import { useMemo, useState } from 'react';

import { Table } from '@/components/table';

export const meta = { layout: 'padded' } as const;

type SortKey = 'name' | 'role' | 'joined';
type SortDir = 'asc' | 'desc';

const team = [
  { name: 'Ana Costa', role: 'Designer', joined: '2024-02-12' },
  { name: 'Bruno Silva', role: 'Engineer', joined: '2023-08-30' },
  { name: 'Carla Mendes', role: 'PM', joined: '2025-01-04' },
  { name: 'Diogo Pinto', role: 'Engineer', joined: '2022-11-19' },
  { name: 'Elisa Faria', role: 'Designer', joined: '2024-09-22' },
];

export default function TableSortablePreview() {
  const [sortKey, setSortKey] = useState<SortKey>('name');
  const [sortDir, setSortDir] = useState<SortDir>('asc');

  const sorted = useMemo(() => {
    return [...team].sort((a, b) => {
      const cmp = a[sortKey].localeCompare(b[sortKey]);
      return sortDir === 'asc' ? cmp : -cmp;
    });
  }, [sortKey, sortDir]);

  const sortFor = (key: SortKey) =>
    sortKey === key ? sortDir : (false as const);

  const handleSort = (key: SortKey) => () => {
    if (sortKey === key) {
      setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
    } else {
      setSortKey(key);
      setSortDir('asc');
    }
  };

  return (
    <Table>
      <Table.Header>
        <Table.Row>
          <Table.SortableHead
            sort={sortFor('name')}
            onSort={handleSort('name')}
          >
            Name
          </Table.SortableHead>
          <Table.SortableHead
            sort={sortFor('role')}
            onSort={handleSort('role')}
          >
            Role
          </Table.SortableHead>
          <Table.SortableHead
            sort={sortFor('joined')}
            onSort={handleSort('joined')}
          >
            Joined
          </Table.SortableHead>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {sorted.map((member) => (
          <Table.Row key={member.name}>
            <Table.Cell className="font-medium">{member.name}</Table.Cell>
            <Table.Cell>{member.role}</Table.Cell>
            <Table.Cell>{member.joined}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Row selection

Header checkbox toggles all rows; selected rows get a data-selected attribute that the row style picks up.

TaskPriorityAssignee
Review PR #482HighAna
Update onboarding copyLowBruno
Fix flaky checkout testHighCarla
Audit color tokensMediumDiogo
import { useState } from 'react';

import { Checkbox } from '@/components/checkbox';
import { Table } from '@/components/table';

export const meta = { layout: 'padded' } as const;

const tasks = [
  { id: 't-1', title: 'Review PR #482', priority: 'High', assignee: 'Ana' },
  {
    id: 't-2',
    title: 'Update onboarding copy',
    priority: 'Low',
    assignee: 'Bruno',
  },
  {
    id: 't-3',
    title: 'Fix flaky checkout test',
    priority: 'High',
    assignee: 'Carla',
  },
  {
    id: 't-4',
    title: 'Audit color tokens',
    priority: 'Medium',
    assignee: 'Diogo',
  },
];

export default function TableWithSelectionPreview() {
  const [selected, setSelected] = useState<Set<string>>(new Set());

  const allSelected = selected.size === tasks.length;
  const someSelected = selected.size > 0 && !allSelected;

  const toggleAll = () => {
    setSelected(allSelected ? new Set() : new Set(tasks.map((t) => t.id)));
  };

  const toggleOne = (id: string) => () => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  return (
    <Table>
      <Table.Header>
        <Table.Row>
          <Table.Head className="w-10">
            <Checkbox
              checked={allSelected}
              indeterminate={someSelected}
              onChange={toggleAll}
              aria-label="Select all rows"
            />
          </Table.Head>
          <Table.Head>Task</Table.Head>
          <Table.Head>Priority</Table.Head>
          <Table.Head>Assignee</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {tasks.map((task) => {
          const isSelected = selected.has(task.id);
          return (
            <Table.Row key={task.id} data-selected={isSelected || undefined}>
              <Table.Cell>
                <Checkbox
                  checked={isSelected}
                  onChange={toggleOne(task.id)}
                  aria-label={`Select ${task.title}`}
                />
              </Table.Cell>
              <Table.Cell className="font-medium">{task.title}</Table.Cell>
              <Table.Cell>{task.priority}</Table.Cell>
              <Table.Cell>{task.assignee}</Table.Cell>
            </Table.Row>
          );
        })}
      </Table.Body>
    </Table>
  );
}

Data table (with TanStack Table)

For sorting, filtering, selection, and pagination together, compose Table with @tanstack/react-table. This is not a hard dependency of the primitive — you only need it when you want the engine.


          pnpm add @tanstack/react-table
        

The recipe:

  1. Define ColumnDef<T>[]accessorKey, header, cell, optional meta for cell alignment.
  2. Call useReactTable with the row models you need (getCoreRowModel, getSortedRowModel, getPaginationRowModel).
  3. Map table.getHeaderGroups() into Table.Head / Table.SortableHead based on column.getCanSort().
  4. Map table.getRowModel().rows into Table.Row / Table.Cell. Use flexRender(cell.column.columnDef.cell, cell.getContext()) for the cell body.
  5. Wire Pagination to table.getPageCount(), table.previousPage(), and table.nextPage().
#1024Acme CorpShipped2026-04-22$2,480.00
#1025GlobexPending2026-04-22$415.00
#1026InitechDelivered2026-04-21$8,120.00
#1027UmbrellaRefunded2026-04-20$320.00
#1028SoylentShipped2026-04-20$1,675.00

0 of 12 selected

import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  type SortingState,
  useReactTable,
} from '@tanstack/react-table';
import { useMemo, useState } from 'react';

import { Checkbox } from '@/components/checkbox';
import {
  Pagination,
  usePagination,
} from '@/components/pagination';
import { Table } from '@/components/table';

export const meta = { layout: 'padded' } as const;

type Order = {
  id: string;
  customer: string;
  status: 'Pending' | 'Shipped' | 'Delivered' | 'Refunded';
  total: number;
  createdAt: string;
};

const orders: Order[] = [
  {
    id: '#1024',
    customer: 'Acme Corp',
    status: 'Shipped',
    total: 2480,
    createdAt: '2026-04-22',
  },
  {
    id: '#1025',
    customer: 'Globex',
    status: 'Pending',
    total: 415,
    createdAt: '2026-04-22',
  },
  {
    id: '#1026',
    customer: 'Initech',
    status: 'Delivered',
    total: 8120,
    createdAt: '2026-04-21',
  },
  {
    id: '#1027',
    customer: 'Umbrella',
    status: 'Refunded',
    total: 320,
    createdAt: '2026-04-20',
  },
  {
    id: '#1028',
    customer: 'Soylent',
    status: 'Shipped',
    total: 1675,
    createdAt: '2026-04-20',
  },
  {
    id: '#1029',
    customer: 'Massive Dynamic',
    status: 'Pending',
    total: 590,
    createdAt: '2026-04-19',
  },
  {
    id: '#1030',
    customer: 'Tyrell',
    status: 'Delivered',
    total: 4250,
    createdAt: '2026-04-18',
  },
  {
    id: '#1031',
    customer: 'Wayne Ent.',
    status: 'Shipped',
    total: 980,
    createdAt: '2026-04-18',
  },
  {
    id: '#1032',
    customer: 'Stark Ind.',
    status: 'Pending',
    total: 1340,
    createdAt: '2026-04-17',
  },
  {
    id: '#1033',
    customer: 'Wonka',
    status: 'Delivered',
    total: 215,
    createdAt: '2026-04-16',
  },
  {
    id: '#1034',
    customer: 'Cyberdyne',
    status: 'Shipped',
    total: 6700,
    createdAt: '2026-04-15',
  },
  {
    id: '#1035',
    customer: 'Hooli',
    status: 'Refunded',
    total: 110,
    createdAt: '2026-04-15',
  },
];

const formatAmount = (value: number) =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(value);

export default function TableDataTablePreview() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [rowSelection, setRowSelection] = useState({});

  const columns = useMemo<ColumnDef<Order>[]>(
    () => [
      {
        id: 'select',
        size: 40,
        header: ({ table }) => (
          <Checkbox
            aria-label="Select all rows"
            checked={table.getIsAllPageRowsSelected()}
            indeterminate={table.getIsSomePageRowsSelected()}
            onChange={(e) =>
              table.toggleAllPageRowsSelected(!!e.currentTarget.checked)
            }
          />
        ),
        cell: ({ row }) => (
          <Checkbox
            aria-label={`Select order ${row.original.id}`}
            checked={row.getIsSelected()}
            onChange={(e) => row.toggleSelected(!!e.currentTarget.checked)}
          />
        ),
        enableSorting: false,
      },
      {
        accessorKey: 'id',
        header: 'Order',
        cell: (info) => (
          <span className="font-medium">{info.getValue<string>()}</span>
        ),
      },
      { accessorKey: 'customer', header: 'Customer' },
      { accessorKey: 'status', header: 'Status' },
      {
        accessorKey: 'createdAt',
        header: 'Created',
      },
      {
        accessorKey: 'total',
        header: 'Total',
        cell: (info) => formatAmount(info.getValue<number>()),
        meta: { align: 'end' as const },
      },
    ],
    []
  );

  const table = useReactTable({
    data: orders,
    columns,
    state: { sorting, rowSelection },
    onSortingChange: setSorting,
    onRowSelectionChange: setRowSelection,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 5 } },
  });

  const pageCount = table.getPageCount();
  const pageIndex = table.getState().pagination.pageIndex;
  const items = usePagination({ page: pageIndex + 1, count: pageCount });

  const selectedCount = table.getSelectedRowModel().rows.length;

  return (
    <div className="flex flex-col gap-4">
      <Table>
        <Table.Header>
          {table.getHeaderGroups().map((headerGroup) => (
            <Table.Row key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                const align = (
                  header.column.columnDef.meta as
                    | { align?: 'start' | 'center' | 'end' }
                    | undefined
                )?.align;
                const canSort = header.column.getCanSort();
                const sort = header.column.getIsSorted();

                if (canSort) {
                  return (
                    <Table.SortableHead
                      key={header.id}
                      align={align}
                      sort={sort === false ? false : sort}
                      onSort={
                        header.column.getToggleSortingHandler() ?? (() => {})
                      }
                      style={
                        header.getSize() !== 150
                          ? { width: header.getSize() }
                          : undefined
                      }
                    >
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                    </Table.SortableHead>
                  );
                }

                return (
                  <Table.Head
                    key={header.id}
                    align={align}
                    style={
                      header.getSize() !== 150
                        ? { width: header.getSize() }
                        : undefined
                    }
                  >
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
                  </Table.Head>
                );
              })}
            </Table.Row>
          ))}
        </Table.Header>
        <Table.Body>
          {table.getRowModel().rows.length === 0 ? (
            <Table.Row>
              <Table.Cell
                colSpan={columns.length}
                align="center"
                className="h-24 text-foreground-secondary"
              >
                No results.
              </Table.Cell>
            </Table.Row>
          ) : (
            table.getRowModel().rows.map((row) => (
              <Table.Row
                key={row.id}
                data-selected={row.getIsSelected() || undefined}
              >
                {row.getVisibleCells().map((cell) => {
                  const align = (
                    cell.column.columnDef.meta as
                      | { align?: 'start' | 'center' | 'end' }
                      | undefined
                  )?.align;
                  return (
                    <Table.Cell key={cell.id} align={align}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </Table.Cell>
                  );
                })}
              </Table.Row>
            ))
          )}
        </Table.Body>
      </Table>

      <div className="flex items-center justify-between gap-4">
        <p className="text-foreground-secondary text-sm">
          {selectedCount} of {table.getFilteredRowModel().rows.length} selected
        </p>

        <Pagination className="w-auto">
          <Pagination.List>
            <Pagination.Item>
              <Pagination.Previous
                onClick={() => table.previousPage()}
                disabled={!table.getCanPreviousPage()}
              />
            </Pagination.Item>

            {items.map((item) =>
              item.type === 'page' ? (
                <Pagination.Item key={item.key}>
                  <Pagination.Link
                    isActive={item.selected}
                    onClick={() => table.setPageIndex(item.value - 1)}
                  >
                    {item.value}
                  </Pagination.Link>
                </Pagination.Item>
              ) : (
                <Pagination.Item key={item.key}>
                  <Pagination.Ellipsis />
                </Pagination.Item>
              )
            )}

            <Pagination.Item>
              <Pagination.Next
                onClick={() => table.nextPage()}
                disabled={!table.getCanNextPage()}
              />
            </Pagination.Item>
          </Pagination.List>
        </Pagination>
      </div>
    </div>
  );
}

Best Practices

  1. Scope:

    • This primitive is the styled <table> — not a data engine. For ≤1k rows in a dashboard, compose with TanStack Table as shown above.
    • For 100k+ rows you’ll want virtualization. Reach for @tanstack/react-virtual on top of TanStack Table, or switch to a dedicated data grid (AG Grid, LyteNyte, MUI X DataGrid Pro).
    • Out of scope here: column resize/reorder, pinning, grouping, range selection, inline editing. If you need them, compose them at the call site or pick a heavier tool.
  2. Alignment:

    • Use align="end" on numeric columns and their header. It makes magnitudes scannable.
    • Keep text columns left-aligned. Centered text in tables is rarely the right choice.
  3. Accessibility:

    • Use <Table.Caption> to describe the table’s purpose — it’s announced by screen readers.
    • Table.SortableHead sets aria-sort automatically; don’t override it.
    • When using checkboxes for selection, set aria-label on each — column headers alone aren’t enough context.
    • For a row that acts as a header for its row (e.g. a name column in a record list), you can render <Table.Head scope="row"> inside Table.Body.
  4. Sticky headers:

    • sticky on Table.Header requires the table’s scroll container to have a fixed height. The default wrapper around <table> only handles horizontal overflow — wrap it in your own max-h-* container for vertical sticky behavior.
  5. Empty and loading states:

    • Render a single full-width row with colSpan={columns.length} for “No results.”
    • For loading, replace cell contents with <Skeleton /> rather than swapping the table for a spinner — preserves layout and prevents jumpy reflows.

Previous

Switch

Next

Tabs