# Table

Displays rows of data in columns, with optional sortable headers and pinned columns.

## Installation

```tsx
import {
  Table,
  TableHeader,
  TableBody,
  TableRow,
  TableHead,
  TableCell,
  TableActionCell,
  TableEditableCell,
} from "@makeplane/propel/components/table";
```
## Usage

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";

const COLUMNS = ["Name", "Email", "Account type"];

const PEOPLE = [
  { name: "Astra", email: "astra.terra@example.com", role: "Admin" },
  { name: "Nova", email: "nova.star@example.com", role: "Member" },
  { name: "Lyra", email: "lyra.constellation@example.com", role: "Guest" },
];

export default function BasicDemo() {
  return (
    <Table variant="table">
      <TableHeader>
        <TableRow>
          {COLUMNS.map((c) => (
            <TableHead key={c} pinned="none" label={c} />
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {PEOPLE.map((person) => (
          <TableRow key={person.email}>
            <TableCell pinned="none" padding="cell">
              {person.name}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.email}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.role}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

`Table` requires `variant` (`table` | `spreadsheet` | `plain`). Plain `TableHead` / `TableCell` require `pinned`
(`none` | `start` | `end`). Plain cells also require `padding` (`cell` for normal content, `trigger`
when a full-cell control fills the cell). `TableActionCell` and `TableEditableCell` set
`pinned="none"` and `padding="trigger"` for you.

Name the table for assistive tech with a preceding heading, or pass `aria-label` / `aria-labelledby`
on `Table` when the page has more than one data table. Header cells default to `scope="col"`; for a
leading column that names each row, pass `scope="row"` on that cell.

## Examples

### Spreadsheet

`variant="spreadsheet"` fully borders every cell into a grid, in contrast to `variant="table"` which
keeps row dividers only.

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";

const COLUMNS = ["Name", "Display name", "Email", "Account type"];

const PEOPLE = [
  { name: "Astra", display: "astra", email: "astra.terra@example.com", role: "Admin" },
  { name: "Nova", display: "nova", email: "nova.star@example.com", role: "Member" },
  { name: "Lyra", display: "lyra", email: "lyra.constellation@example.com", role: "Guest" },
];

export default function SpreadsheetDemo() {
  return (
    <Table variant="spreadsheet">
      <TableHeader>
        <TableRow>
          {COLUMNS.map((c) => (
            <TableHead key={c} pinned="none" label={c} />
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {PEOPLE.map((person) => (
          <TableRow key={person.email}>
            <TableCell pinned="none" padding="cell">
              {person.name}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.display}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.email}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.role}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

### Plain

`variant="plain"` keeps the `table` metrics and removes the frame and the fills, for a table that
sits directly on the page. Every row keeps its divider, the last one included. The header and
pinned cells are sticky, so they paint an opaque fill, `surface-1` by default. On any other surface,
set the `--table-surface` CSS custom property on an ancestor of the table to that surface's fill.
This example sits on a `layer-2` pane, so its wrapper sets `--table-surface: var(--bg-layer-2)`.

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";

const COLUMNS = ["Page", "Owner", "Nested", "Last activity"];

const PAGES = [
  { title: "Engineering Team Workflow", owner: "Dancia Clara", nested: "", activity: "6 days ago" },
  { title: "Platform Engineering", owner: "Vamsi Kurama", nested: "21", activity: "1 month ago" },
  { title: "Architecture and Design", owner: "Sriram V", nested: "5", activity: "5 months ago" },
];

export default function PlainDemo() {
  return (
    <div className="w-full [--table-surface:var(--bg-layer-2)]">
      <Table variant="plain">
        <TableHeader>
          <TableRow>
            {COLUMNS.map((c) => (
              <TableHead key={c} pinned="none" label={c} />
            ))}
          </TableRow>
        </TableHeader>
        <TableBody>
          {PAGES.map((page) => (
            <TableRow key={page.title}>
              <TableCell pinned="none" padding="cell">
                {page.title}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {page.owner}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {page.nested}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {page.activity}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```

### Expandable rows

Use `TableExpandCell` as the first cell of every row. A row with nested rows passes `expanded`,
`onExpandedChange` and an `aria-label` for the chevron button (keep the name the same in both states;
`aria-expanded` announces the state); a row without any passes none of
them and gets a spacer of the same size, so the titles stay aligned. Nested rows are ordinary
`TableRow`s rendered after their parent while it is expanded, with `depth={1}` on their first cell.
`inset="expand"` on the column's `TableHead` lines the header label up with the row content. It
works in every `variant`.

`TableNestedRows` renders the nested rows for you. It shows the first `maxVisible` (5 by default)
and, for a longer run, a "show more" row that reveals the rest and moves focus to the first row it
revealed. Collapsing the parent resets it. It is optional: to page nested rows from a server or
reveal them a page at a time, compose `TableRow` and `TableShowMoreCell` yourself.

The table holds no expand state. You own which rows are open.

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableExpandCell,
  TableHead,
  TableHeader,
  TableNestedRows,
  TableRow,
} from "@makeplane/propel/components/table";
import * as React from "react";

const PAGES = [
  { id: "workflow", title: "Engineering Team Workflow", owner: "Dancia Clara", nested: 0 },
  { id: "platform", title: "Platform Engineering", owner: "Vamsi Kurama", nested: 8 },
  { id: "problems", title: "Problem statements", owner: "Aaryan Khandelwal", nested: 2 },
];

export default function ExpandableRowsDemo() {
  const [open, setOpen] = React.useState<Record<string, boolean>>({ platform: true });

  return (
    <Table variant="table">
      <TableHeader>
        <TableRow>
          <TableHead label="Page" inset="expand" />
          <TableHead label="Owner" />
          <TableHead label="Nested" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {PAGES.map((page) => {
          const expanded = open[page.id] ?? false;
          return (
            <React.Fragment key={page.id}>
              <TableRow>
                {page.nested > 0 ? (
                  <TableExpandCell
                    expanded={expanded}
                    onExpandedChange={(next) => setOpen((o) => ({ ...o, [page.id]: next }))}
                    aria-label={`Pages nested in ${page.title}`}
                  >
                    {page.title}
                  </TableExpandCell>
                ) : (
                  <TableExpandCell>{page.title}</TableExpandCell>
                )}
                <TableCell>{page.owner}</TableCell>
                <TableCell>{page.nested > 0 ? page.nested : null}</TableCell>
              </TableRow>
              {expanded ? (
                <TableNestedRows
                  items={Array.from({ length: page.nested }, (_unused, index) => index + 1)}
                  getKey={(number) => number}
                  colSpan={3}
                  showMoreLabel={(hidden) => `Show ${hidden} more`}
                  renderRow={(number, _index, { depth }) => (
                    <TableRow>
                      <TableExpandCell depth={depth}>{`Nested page ${number}`}</TableExpandCell>
                      <TableCell>{page.owner}</TableCell>
                      <TableCell />
                    </TableRow>
                  )}
                />
              ) : null}
            </React.Fragment>
          );
        })}
      </TableBody>
    </Table>
  );
}
```

### Sortable

A header with `sortable` and `onSort` renders its label as a button with a sort chevron. Clicking
cycles none → asc → desc; `aria-sort` is set for asc/desc and omitted while unsorted.

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  type TableHeadSort,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";
import * as React from "react";

const PEOPLE = [
  { name: "Astra", email: "astra.terra@example.com", role: "Admin" },
  { name: "Nova", email: "nova.star@example.com", role: "Member" },
  { name: "Lyra", email: "lyra.constellation@example.com", role: "Guest" },
];

export default function SortableDemo() {
  const [sort, setSort] = React.useState<TableHeadSort>("none");
  const cycle = () => setSort((s) => (s === "none" ? "asc" : s === "asc" ? "desc" : "none"));
  const rows =
    sort === "none"
      ? PEOPLE
      : [...PEOPLE].sort((a, b) => a.name.localeCompare(b.name) * (sort === "asc" ? 1 : -1));

  return (
    <Table variant="table">
      <TableHeader>
        <TableRow>
          <TableHead pinned="none" label="Name" sortable sort={sort} onSort={cycle} />
          <TableHead pinned="none" label="Email" />
          <TableHead pinned="none" label="Account type" />
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((person) => (
          <TableRow key={person.email}>
            <TableCell pinned="none" padding="cell">
              {person.name}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.email}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.role}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

### Rich rows

Cells can carry a leading `Avatar`, an inline `TableEditableCell` that opens a menu to change its
value, and a trailing icon-only `TableActionCell` for row actions. While an editable cell's menu is
open, pass `selected` for the stronger open tint — it's visual only; the menu trigger already
exposes open state via `aria-expanded`.

```tsx
import { Avatar } from "@makeplane/propel/components/avatar";
import { Icon } from "@makeplane/propel/components/icon";
import { MenuContent, MenuItem } from "@makeplane/propel/components/menu";
import {
  Table,
  TableActionCell,
  TableBody,
  TableCell,
  TableEditableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";
import { EditOutline, DeleteOutline } from "@makeplane/propel/icons";
import * as React from "react";

const ROLES = ["Admin", "Member", "Guest"];

const INITIAL = [
  { name: "Astra", email: "astra.terra@example.com", role: "Admin" },
  { name: "Nova", email: "nova.star@example.com", role: "Member" },
  { name: "Lyra", email: "lyra.constellation@example.com", role: "Guest" },
];

export default function RichRowsDemo() {
  const [people, setPeople] = React.useState(INITIAL);
  const [selectedEmail, setSelectedEmail] = React.useState<string | null>(null);
  const setRole = (email: string, role: string) =>
    setPeople((rows) => rows.map((r) => (r.email === email ? { ...r, role } : r)));
  const remove = (email: string) => setPeople((rows) => rows.filter((r) => r.email !== email));

  return (
    <Table variant="table">
      <TableHeader>
        <TableRow>
          <TableHead pinned="none" label="Name" />
          <TableHead pinned="none" label="Email" />
          <TableHead pinned="none" label="Account type" />
          <TableHead pinned="none" label="Actions" visuallyHidden />
        </TableRow>
      </TableHeader>
      <TableBody>
        {people.map((person) => (
          <TableRow key={person.email}>
            <TableCell
              pinned="none"
              padding="cell"
              startIcon={<Avatar size="2xs" fallback={person.name.charAt(0)} />}
            >
              {person.name}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.email}
            </TableCell>
            <TableEditableCell
              value={person.role}
              selected={selectedEmail === person.email}
              aria-label={`Account type for ${person.name}: ${person.role}`}
              onOpenChange={(next) => setSelectedEmail(next ? person.email : null)}
            >
              <MenuContent>
                {ROLES.map((role) => (
                  <MenuItem
                    key={role}
                    label={role}
                    selected={role === person.role}
                    onClick={() => setRole(person.email, role)}
                  />
                ))}
              </MenuContent>
            </TableEditableCell>
            <TableActionCell aria-label={`Options for ${person.name}`}>
              <MenuContent>
                <MenuItem icon={<Icon icon={EditOutline} tint="secondary" />} label="Edit" />
                <MenuItem
                  variant="danger"
                  icon={<Icon icon={DeleteOutline} />}
                  label="Delete"
                  onClick={() => remove(person.email)}
                />
              </MenuContent>
            </TableActionCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

### Large tables

`Table` paints the rows you pass — it is not a data grid. Prefer [pagination](#with-pagination) or
app-side virtualization for many rows. Each `TableEditableCell` / `TableActionCell` mounts its own
`Menu`; at high row counts, share one menu with
[`createMenuHandle`](/components/menu#detached-triggers) instead of a menu per cell.

### Cell slots and disabled

`TableCell` accepts `endIcon` (and `startIcon`) for trailing chrome. `TableEditableCell` and
`TableActionCell` accept `disabled` so the full-cell trigger cannot open its menu.

```tsx
import { Icon } from "@makeplane/propel/components/icon";
import { MenuContent, MenuItem } from "@makeplane/propel/components/menu";
import {
  Table,
  TableActionCell,
  TableBody,
  TableCell,
  TableEditableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";
import { MailOutline, EditOutline, DeleteOutline } from "@makeplane/propel/icons";

export default function SlotsAndDisabledDemo() {
  return (
    <Table variant="table">
      <TableHeader>
        <TableRow>
          <TableHead pinned="none" label="Name" />
          <TableHead pinned="none" label="Email" />
          <TableHead pinned="none" label="Account type" />
          <TableHead pinned="none" label="Actions" visuallyHidden />
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell pinned="none" padding="cell">
            Astra
          </TableCell>
          <TableCell
            pinned="none"
            padding="cell"
            endIcon={<Icon icon={MailOutline} tint="secondary" />}
          >
            astra.terra@example.com
          </TableCell>
          <TableEditableCell value="Admin" aria-label="Account type for Astra: Admin">
            <MenuContent>
              <MenuItem label="Admin" selected />
              <MenuItem label="Member" />
              <MenuItem label="Guest" />
            </MenuContent>
          </TableEditableCell>
          <TableActionCell aria-label="Options for Astra">
            <MenuContent>
              <MenuItem icon={<Icon icon={EditOutline} tint="secondary" />} label="Edit" />
              <MenuItem variant="danger" icon={<Icon icon={DeleteOutline} />} label="Delete" />
            </MenuContent>
          </TableActionCell>
        </TableRow>
        <TableRow>
          <TableCell pinned="none" padding="cell">
            Nova
          </TableCell>
          <TableCell pinned="none" padding="cell">
            nova.star@example.com
          </TableCell>
          <TableEditableCell value="Member" disabled aria-label="Account type for Nova: Member">
            <MenuContent>
              <MenuItem label="Member" selected />
            </MenuContent>
          </TableEditableCell>
          <TableActionCell disabled aria-label="Options for Nova">
            <MenuContent>
              <MenuItem label="Edit" />
            </MenuContent>
          </TableActionCell>
        </TableRow>
      </TableBody>
    </Table>
  );
}
```

### With pagination

`Table` and `Pagination` are separate — slice the rows for the current page and drive `page` /
`pageSize` from the pager below.

```tsx
import { Avatar } from "@makeplane/propel/components/avatar";
import { Pagination } from "@makeplane/propel/components/pagination";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";
import * as React from "react";

const DIRECTORY = Array.from({ length: 23 }, (_, i) => {
  const names = ["Astra", "Nova", "Lyra", "Vega"];
  const name = names[i % names.length];
  return {
    name: `${name} ${i + 1}`,
    email: `user${i + 1}@example.com`,
    role: i % 3 === 0 ? "Admin" : i % 3 === 1 ? "Member" : "Guest",
  };
});

export default function WithPaginationDemo() {
  const [page, setPage] = React.useState(1);
  const [pageSize, setPageSize] = React.useState(5);
  const pageCount = Math.ceil(DIRECTORY.length / pageSize);
  const start = (page - 1) * pageSize;
  const rows = DIRECTORY.slice(start, start + pageSize);

  return (
    <div className="flex w-190 flex-col gap-3">
      <Table variant="table">
        <TableHeader>
          <TableRow>
            <TableHead pinned="none" label="Name" />
            <TableHead pinned="none" label="Email" />
            <TableHead pinned="none" label="Account type" />
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((person) => (
            <TableRow key={person.email}>
              <TableCell
                pinned="none"
                padding="cell"
                startIcon={<Avatar size="2xs" fallback={person.name.charAt(0)} />}
              >
                {person.name}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {person.email}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {person.role}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <Pagination
        page={page}
        pageCount={pageCount}
        onPageChange={setPage}
        pageSize={{
          value: pageSize,
          options: [5, 10, 25],
          onValueChange: (next) => {
            setPageSize(next);
            setPage(1);
          },
        }}
        range={{
          current: `${start + 1}-${Math.min(start + pageSize, DIRECTORY.length)}`,
          total: DIRECTORY.length,
        }}
      />
    </div>
  );
}
```

### Pinned columns

In a height- and width-constrained frame the header stays pinned to the top on vertical scroll, and a column whose head and cells set `pinned="start"` or `pinned="end"` stays put on horizontal scroll.

```tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@makeplane/propel/components/table";

const PEOPLE = [
  {
    name: "Astra",
    display: "astra",
    email: "astra.terra@example.com",
    role: "Admin",
    billing: "Paid",
  },
  {
    name: "Nova",
    display: "nova",
    email: "nova.star@example.com",
    role: "Member",
    billing: "Paid",
  },
  {
    name: "Lyra",
    display: "lyra",
    email: "lyra.constellation@example.com",
    role: "Guest",
    billing: "Trial",
  },
];

export default function PinnedDemo() {
  return (
    <div className="h-64 w-full max-w-md">
      <Table variant="table">
        <TableHeader>
          <TableRow>
            <TableHead pinned="start" label="Name" />
            <TableHead pinned="none" label="Display name" />
            <TableHead pinned="none" label="Email" />
            <TableHead pinned="none" label="Account type" />
            <TableHead pinned="end" label="Billing status" />
          </TableRow>
        </TableHeader>
        <TableBody>
          {PEOPLE.map((person) => (
            <TableRow key={person.email}>
              <TableCell pinned="start" padding="cell">
                {person.name}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {person.display}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {person.email}
              </TableCell>
              <TableCell pinned="none" padding="cell">
                {person.role}
              </TableCell>
              <TableCell pinned="end" padding="cell">
                {person.billing}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}
```
## API Reference

### Table

The ready-made table: the styled `<table>` wrapped in a scroll frame (rounded and
hairline-bordered, or bare for `variant="plain"`), sharing its layout `variant` with the
rows/cells/heads via context. The Base UI `ScrollArea` behavior grafts onto the styled
`TableScrollArea` frame + viewport (and the overlay scrollbars) via `render`, behavior part
outer.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` (required) | `"table" \| "spreadsheet" \| "plain"` | — | Layout (required). `table` draws a rounded frame with row dividers only; `spreadsheet` draws the same frame with a full grid; `plain` drops the frame and the fills, leaving row dividers on the host surface. Its sticky header and pinned cells paint `surface-1`; on any other surface, set the `--table-surface` CSS custom property on an ancestor to that surface's fill. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### TableHeader

Header section (`<thead>`). Holds a single `TableRow` of `TableHead` cells.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### TableBody

Body section (`<tbody>`). Holds the data `TableRow`s.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### TableRow

A ready-made table row (`<tr>`), taking its resting fill from the surrounding `Table`'s
`variant`. `selected` paints the selected tint, `interactive` marks the row as a click target,
and `section` makes it a non-data row that labels the rows beneath it.

`selected` is a visual state only. Pair it with a `TableSelectCell`, whose checkbox carries the
real state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `section` | `NonNullable<boolean \| null>` | — | A non-data row that labels the group of rows beneath it. It sits on `layer-1` — the header's fill — and takes no hover tint, because there is nothing to act on. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `selected` | `NonNullable<boolean \| null>` | — | Figma's `state=selected`. Also drives the pinned-cell overlay via `data-selected`. |
| `interactive` | `NonNullable<boolean \| null>` | — | Whether the row itself is a click target. Figma's hover state carries `cursor-pointer`, but a read-only table should not advertise one, so the caller opts in. |

### TableHead

A ready-made header cell: a plain title, arbitrary `children`, or (when sortable) either of those
inside a sort-cycling button with a chevron.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"center" \| "start" \| "end"` | — | Column alignment. Pair it with the matching `align` on that column's cells. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | — |  |
| `inset` | `"none" \| "expand"` | — | Inline padding. `expand` pushes the label past the expand control of a column of `TableExpandCell`s (the control's size plus one cell gap), so it lines up with the row content instead of the chevron. The whole inline padding lives here so one padding pairing is emitted per value. |
| `label` | `string` | — | Header label. Rendered in a truncating title region. Omit it only when `children` supplies the header's own content — a sort/filter menu, a select-all checkbox, a tooltip trigger. Pass `label` as well in that case if the content has no accessible text of its own: `children` replace the title region, so the label is kept as screen-reader-only text beside them rather than shown. |
| `children` | `string \| number \| bigint \| boolean \| ReactElement<unknown, string \| JSXElementConstructor<any>> \| Iterable<ReactNode> \| ... 47 more ...` | — | Header content, when a plain label is not enough. Replaces the title region; a sortable header still wraps it in the sort control. The cell's own chrome — background, borders, padding, stickiness, alignment — is unaffected. |
| `visuallyHidden` | `boolean` | `false` | Visually hide the label while keeping it available to assistive tech. |
| `sortable` | `boolean` | `false` | Enable sorting on this header. Renders a sort trigger when `onSort` is also provided. Sets `aria-sort` for `asc` / `desc` only (omitted while unsorted). |
| `sort` | `"desc" \| "none" \| "asc"` | `none` | Current sort state for a sortable header. |
| `onSort` | `(() => void)` | — | Click handler for the sort control; required (with `sortable`) for the header to be interactive. |

### TableCell

A ready-made data cell: optional leading/trailing slots around a truncating content region.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | — |  |
| `padding` | `"cell" \| "trigger"` | — |  |
| `startIcon` | `ReactNode` | — | Leading element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`. |
| `endIcon` | `ReactNode` | — | Trailing element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`. |
| `children` | `ReactNode` | — | Cell content. |
| `align` | `"center" \| "start" \| "end"` | `"start"` | Column alignment. Pair it with the same `align` on this column's `TableHead`. It moves the text inside the growing content region, so the leading and trailing slots keep their edges. |

### TableExpandCell

The first cell (`<td>`) of a row in a table with nested rows. With `expanded` it leads with a
chevron button that reveals or hides the row's nested rows; without it, with an empty spacer of
the same size. `depth` indents the cell for a nested row.

The cell owns no state and renders no nested rows. The consumer holds which rows are open and
renders the nested `TableRow`s after the parent while it is expanded, the same way it owns the
sort order. The table stays a plain `<table>`: the control is a button with `aria-expanded`, and
focus stays on it after it is activated.

Activating the control does not reach the row: its click, and the Enter or Space keydown that
activates it, stop at the control, so an `interactive` row's own click or keyboard handler does
not also fire.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | — |  |
| `startIcon` | `ReactNode` | — | Leading element beside the cell text, e.g. `<Icon icon={...} />`. It sits after the expand control. |
| `children` | `ReactNode` | — | Cell content. |
| `depth` | `number` | `0` | Nesting level of this row. `0` is a top-level row; each level indents the cell by 24px, with no upper limit. Give the rows a parent reveals a `depth` one higher than the parent's. |
| `expanded` | `boolean` | — | Whether this row's nested rows are showing. Pass it (with `onExpandedChange`) on a row that has nested rows; omit it on a row that has none, which then renders an empty spacer so its content stays aligned with its siblings. |
| `onExpandedChange` | `((expanded: boolean) => void)` | — | Called with the next state when the expand control is activated. |
| `aria-label` | `string` | — | Accessible name for the expand control. Required, because it has no visible text. Name what it reveals and keep the name the same in both states, e.g. `"Pages nested in Platform Engineering"`. `aria-expanded` already announces the state, so a name that switches between "Expand" and "Collapse" would say it twice. |

### TableNestedRows

The nested rows under an expanded parent, with the "show more" behaviour built in. It renders the
first `maxVisible` rows and, while any are hidden, a `TableShowMoreCell` row that reveals the
rest. Render it after the parent `TableRow` while the parent is expanded:

```tsx
{expanded ? <TableNestedRows items={page.children} … /> : null}
```

It renders rows only, with no wrapper element, so it sits directly in a `TableBody`. Revealing
the rest removes the button, so focus moves to the first row it revealed instead of being lost.
Collapsing the parent unmounts the group, so it opens on its first `maxVisible` rows again.

It is optional. For anything it does not cover — paging nested rows from a server, revealing a
page at a time — compose `TableRow`, `TableExpandCell` and `TableShowMoreCell` directly.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `readonly Item[]` | — | The nested rows' data, in display order. |
| `getKey` (required) | `(item: Item, index: number) => Key` | — | A stable key for an item. |
| `renderRow` (required) | `(item: Item, index: number, group: { depth: number; }) => ReactNode` | — | Renders one nested row: a `TableRow` whose first cell is a `TableExpandCell`. The third argument carries this group's `depth`; pass it to that cell so the rows and the "show more" button are indented from one value. |
| `colSpan` (required) | `number` | — | How many columns the table has, so the "show more" row spans all of them. |
| `showMoreLabel` (required) | `(hidden: number) => string` | — | The "show more" button's text, given how many rows are still hidden, e.g. `"Show 16 more"`. |
| `maxVisible` | `number` | `5` | How many rows to show before the "show more" row. A group with this many rows or fewer shows them all and no button. |
| `depth` | `number` | `1` | Nesting level of these rows. It indents the "show more" button and is handed to `renderRow` for the rows themselves. |

### TableShowMoreCell

A cell (`<td>`) holding a text button that reveals the rest of a long run of nested rows. Put it
alone in a `TableRow` after the nested rows shown so far, and pass `colSpan` so it spans the
table. It is indented to line up with the nested rows' content.

Like the expand control, activating the button does not reach the row: its click, and the Enter
or Space keydown that activates it, stop at the button, so an `interactive` row's own handler
does not also fire.

The cell does not count rows: the consumer decides how many nested rows to render first and what
the label says.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` | — | The button text, e.g. `"Show 16 more"`. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | — |  |
| `onClick` | `MouseEventHandler<HTMLElement>` | — | Called when the button is activated. |
| `depth` | `number` | `1` | Nesting level of the rows this button reveals more of, so it lines up with their content. |

### TableActionCell

An icon-only action cell (`<td>`) that opens a row-actions menu.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | — | The menu of row actions. |
| `aria-label` | `string` | — | Defines a string value that labels the current element. Accessible name for the trigger (e.g. "Row options"). Required (icon-only). @see aria-labelledby. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | `none` | Which inline edge this cell pins to while the table scrolls sideways. Pin the matching `TableHead` the same way — nothing derives it. |
| `icon` | `ReactNode` | `an ellipsis.` | Trigger glyph. |
| `open` | `boolean` | — | Whether the menu is open (controlled). Pair with `onOpenChange`. |
| `defaultOpen` | `boolean` | `false` | Default open state for an uncontrolled cell. |
| `onOpenChange` | `((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)` | — | Called when the menu requests to open or close. |
| `disabled` | `boolean` | — | Disables the trigger. |

### TableEditableCell

An editable data cell (`<td>`) with a full-cell menu trigger.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `ReactNode` | — | The current value shown in the cell. |
| `children` (required) | `ReactNode` | — | The menu shown when the cell is clicked. |
| `aria-label` | `string` | — | Defines a string value that labels the current element. Accessible name for the trigger. Replaces the visible `value` in the name computation — include the current value when you pass this (e.g. `"Account type for Astra: Admin"`). @see aria-labelledby. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `pinned` | `"none" \| "start" \| "end"` | `none` | Which inline edge this cell pins to while the table scrolls sideways. Pin the matching `TableHead` the same way — nothing derives it. |
| `open` | `boolean` | — | Whether the menu is open (controlled). |
| `defaultOpen` | `boolean` | `false` | Default open state for an uncontrolled cell. |
| `onOpenChange` | `((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)` | — | Called when the menu requests to open or close. |
| `disabled` | `boolean` | — | Disables the trigger so the cell can't be edited. |
| `selected` | `boolean` | — | Visual open-menu tint only (`bg-layer-transparent-selected`). Does not set an ARIA selected state — while the menu is open, `aria-expanded` already exposes that. Don't use this for closed cell selection without your own `aria-*`. |
