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

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 { Pencil, Trash2 } from "lucide-react";
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={Pencil} tint="secondary" />} label="Edit" />
                <MenuItem
                  variant="danger"
                  icon={<Icon icon={Trash2} />}
                  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 { Mail, Pencil, Trash2 } from "lucide-react";

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={Mail} 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={Pencil} tint="secondary" />} label="Edit" />
              <MenuItem variant="danger" icon={<Icon icon={Trash2} />} 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 rounded, hairline-bordered scroll frame, sharing its layout `variant` with the 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"Layout (required). `table` draws row dividers only; `spreadsheet` draws a full grid.
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 `TableRow`s.

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 table row (`<tr>`).

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.

TableHead

A ready-made header cell: a plain title, or (when sortable) a sort-cycling button with a chevron.

PropTypeDefaultDescription
pinned(required)"none" | "start" | "end"
label(required)stringHeader label.
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.
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
pinned(required)"none" | "start" | "end"
padding(required)"cell" | "trigger"
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.
startIconReactNodeLeading element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`.
endIconReactNodeTrailing element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`.
childrenReactNodeCell content.

TableActionCell

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

PropTypeDefaultDescription
children(required)ReactNodeThe menu of row actions.
aria-labelstringDefines 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.
iconReactNodean ellipsis.Trigger glyph.
openbooleanWhether 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.
disabledbooleanDisables the trigger.

TableEditableCell

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

PropTypeDefaultDescription
value(required)ReactNodeThe current value shown in the cell.
children(required)ReactNodeThe menu shown when the cell is clicked.
aria-labelstringDefines 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.
openbooleanWhether 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.
disabledbooleanDisables the trigger so the cell can't be edited.
selectedbooleanVisual 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-*`.