Propel

Table

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

Show code
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>
  );
}

Installation

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

Usage

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.

Show code
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).

Show code
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 TableRows 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.

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

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

Show code
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 or app-side virtualization for many rows. Each TableEditableCell / TableActionCell mounts its own Menu; at high row counts, share one menu with createMenuHandle 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.

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

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

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

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

PropTypeDefaultDescription
renderReactElement<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 TableRows.

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

PropTypeDefaultDescription
sectionNonNullable<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.
renderReactElement<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.
selectedNonNullable<boolean | null>—Figma's state=selected. Also drives the pinned-cell overlay via data-selected.
interactiveNonNullable<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.

PropTypeDefaultDescription
align"center" | "start" | "end"—Column alignment. Pair it with the matching align on that column's cells.
renderReactElement<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 TableExpandCells (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.
labelstring—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.
childrenstring | 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.
visuallyHiddenbooleanfalseVisually hide the label while keeping it available to assistive tech.
sortablebooleanfalseEnable 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"noneCurrent 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.

PropTypeDefaultDescription
renderReactElement<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"—
startIconReactNode—Leading element beside the cell text, e.g. <Icon icon={...} /> or an Avatar.
endIconReactNode—Trailing element beside the cell text, e.g. <Icon icon={...} /> or an Avatar.
childrenReactNode—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 TableRows 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.

PropTypeDefaultDescription
renderReactElement<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"—
startIconReactNode—Leading element beside the cell text, e.g. <Icon icon={...} />. It sits after the expand control.
childrenReactNode—Cell content.
depthnumber0Nesting 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.
expandedboolean—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-labelstring—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.

PropTypeDefaultDescription
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".
maxVisiblenumber5How many rows to show before the "show more" row. A group with this many rows or fewer shows them all and no button.
depthnumber1Nesting 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.

PropTypeDefaultDescription
label(required)string—The button text, e.g. "Show 16 more".
renderReactElement<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"—
onClickMouseEventHandler<HTMLElement>—Called when the button is activated.
depthnumber1Nesting 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.

PropTypeDefaultDescription
children(required)ReactNode—The menu of row actions.
aria-labelstring—Defines a string value that labels the current element. Accessible name for the trigger (e.g. "Row options"). Required (icon-only). @see aria-labelledby.
renderReactElement<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"noneWhich inline edge this cell pins to while the table scrolls sideways. Pin the matching TableHead the same way — nothing derives it.
iconReactNodean ellipsis.Trigger glyph.
openboolean—Whether the menu is open (controlled). Pair with onOpenChange.
defaultOpenbooleanfalseDefault open state for an uncontrolled cell.
onOpenChange((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)—Called when the menu requests to open or close.
disabledboolean—Disables the trigger.

TableEditableCell

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

PropTypeDefaultDescription
value(required)ReactNode—The current value shown in the cell.
children(required)ReactNode—The menu shown when the cell is clicked.
aria-labelstring—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.
renderReactElement<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"noneWhich inline edge this cell pins to while the table scrolls sideways. Pin the matching TableHead the same way — nothing derives it.
openboolean—Whether the menu is open (controlled).
defaultOpenbooleanfalseDefault open state for an uncontrolled cell.
onOpenChange((open: boolean, eventDetails: MenuRootChangeEventDetails) => void)—Called when the menu requests to open or close.
disabledboolean—Disables the trigger so the cell can't be edited.
selectedboolean—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-*.