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 TableContainerProps extends React.ComponentPropsWithRef<"div"> {
  bordered?: boolean;
}

const TableContainer = ({ ref, className, bordered = true, ...props }: TableContainerProps) => {
  return (
    <div
      ref={ref}
      className={cn(
        "relative w-full overflow-x-auto",
        bordered && "rounded-lg border border-border shadow-xs",
        className,
      )}
      {...props}
    />
  );
};

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

const Table = ({ ref, className, density, ...props }: TableProps) => {
  return <table ref={ref} className={cn(tableStyle({ density }), className)} {...props} />;
};

interface TableHeaderProps extends React.ComponentPropsWithRef<"thead"> {
  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/2 font-medium", className)}
      {...props}
    />
  );
};

const TableRow = ({ ref, className, ...props }: React.ComponentPropsWithRef<"tr">) => {
  return (
    <tr
      ref={ref}
      className={cn("transition-colors", "data-selected:bg-accent/4", 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"> {
  sort: "asc" | "desc" | false;
  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, {
  Container: TableContainer,
  Header: TableHeader,
  Body: TableBody,
  Footer: TableFooter,
  Row: TableRow,
  Head: TableHead,
  Cell: TableCell,
  Caption: TableCaption,
  SortableHead: TableSortableHead,
});

export { CompoundTable as Table };

Anatomy

          <Table.Container>
  <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>
</Table.Container>
        

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 renders the bare <table> element. Wrap it in Table.Container when you want a bordered card, horizontal overflow on narrow viewports, or a sticky header — the container is the scroll context, so it owns the border, the rounding, and any height constraint. A plain <Table> with no container is perfectly valid for a table that should flow inline with the page.

API Reference

Table.Container

Optional div wrapper that becomes the table’s scroll container — it handles horizontal overflow and, with a max-h-*, vertical scrolling for a sticky header. Pin a height here, not on an outer element, since position: sticky engages against the nearest scroll container. Extends the div element.

PropDefaultTypeDescription
borderedtruebooleanWrap the table in a bordered card (rounded border + subtle shadow).

Table

Extends the table element. Renders the bare <table> — wrap it in Table.Container for borders, overflow, or sticky headers.

PropDefaultTypeDescription
density"md""sm""md"Vertical padding of body cells.

Table.Header

Extends the thead element.

PropDefaultTypeDescription
sticky-booleanPin 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.

PropDefaultTypeDescription
align"start""start""center""end"Text alignment. Use 'end' for numeric columns.

Table.Cell

Extends the td element.

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

PropDefaultTypeDescription
sort-"asc""desc"falseCurrent sort direction. `false` means unsorted.
onSort-() => voidCalled 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>
  );
}

Wrap the table in Table.Container with a max-h-* and set sticky on Table.Header. The container is the scroll context, so the header pins to its top as the body scrolls.

CommitAuthorMessage
4a2f1cAna CostaFix sticky header scroll context
4a30efBruno SilvaAdd Table.Container part
4a32c2Carla MendesBump dependencies
4a3495Diogo PintoTidy up docs
4a3668Ana CostaFix sticky header scroll context
4a383bBruno SilvaAdd Table.Container part
4a3a0eCarla MendesBump dependencies
4a3be1Diogo PintoTidy up docs
4a3db4Ana CostaFix sticky header scroll context
4a3f87Bruno SilvaAdd Table.Container part
4a415aCarla MendesBump dependencies
4a432dDiogo PintoTidy up docs
4a4500Ana CostaFix sticky header scroll context
4a46d3Bruno SilvaAdd Table.Container part
4a48a6Carla MendesBump dependencies
4a4a79Diogo PintoTidy up docs
4a4c4cAna CostaFix sticky header scroll context
4a4e1fBruno SilvaAdd Table.Container part
4a4ff2Carla MendesBump dependencies
4a51c5Diogo PintoTidy up docs
4a5398Ana CostaFix sticky header scroll context
4a556bBruno SilvaAdd Table.Container part
4a573eCarla MendesBump dependencies
4a5911Diogo PintoTidy up docs
4a5ae4Ana CostaFix sticky header scroll context
4a5cb7Bruno SilvaAdd Table.Container part
4a5e8aCarla MendesBump dependencies
4a605dDiogo PintoTidy up docs
4a6230Ana CostaFix sticky header scroll context
4a6403Bruno SilvaAdd Table.Container part
import { Table } from "@/components/table";

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

const commits = Array.from({ length: 30 }, (_, i) => ({
  hash: (0x4a2f1c + i * 0x1d3).toString(16).slice(0, 7),
  author: ["Ana Costa", "Bruno Silva", "Carla Mendes", "Diogo Pinto"][i % 4],
  message: [
    "Fix sticky header scroll context",
    "Add Table.Container part",
    "Bump dependencies",
    "Tidy up docs",
  ][i % 4],
}));

export default function TableStickyPreview() {
  return (
    <Table.Container className="max-h-72">
      <Table>
        <Table.Header sticky>
          <Table.Row>
            <Table.Head>Commit</Table.Head>
            <Table.Head>Author</Table.Head>
            <Table.Head>Message</Table.Head>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {commits.map((commit) => (
            <Table.Row key={commit.hash}>
              <Table.Cell className="font-mono">{commit.hash}</Table.Cell>
              <Table.Cell>{commit.author}</Table.Cell>
              <Table.Cell>{commit.message}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table>
    </Table.Container>
  );
}

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.Container>
        <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>
      </Table.Container>

      <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 pins to the nearest scroll container, which must have a bounded height for position: sticky to engage. Wrap the table in Table.Container and put the max-h-* on the container itself — not on an outer element, which would scroll the whole block (header included) instead of just the body.
  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