Propel

Table

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

Basic

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 mode="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>
  );
}

Spreadsheet

mode="spreadsheet" fully borders every cell into a grid, in contrast to the default mode="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 mode="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 sortable header renders its label as a button with a sort chevron and reflects the order through aria-sort. Clicking cycles none → asc → desc.

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 mode="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.

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 setRole = (email: string, role: string) =>
    setPeople((rows) => rows.map((r) => (r.email === email ? { ...r, role } : r)));

  return (
    <Table mode="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 magnitude="xs" alt={person.name} fallback={person.name.charAt(0)} />
              }
            >
              {person.name}
            </TableCell>
            <TableCell pinned="none" padding="cell">
              {person.email}
            </TableCell>
            <TableEditableCell value={person.role} aria-label={`Account type for ${person.name}`}>
              <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 tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
              </MenuContent>
            </TableActionCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Installation

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

Props

Table

PropTypeRequiredDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>NoAllows 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.
mode"table" | "spreadsheet"YesLayout (required). `table` draws row dividers only; `spreadsheet` draws a full grid.

TableHead

PropTypeRequiredDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>NoAllows 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"Yes
labelstringYesHeader label.
visuallyHiddenbooleanNoVisually hide the label while keeping it available to assistive tech.
sortablebooleanNoWhether this header is interactive sortable (renders a sort trigger + reflects `aria-sort`).
sort"desc" | "none" | "asc"NoCurrent sort state for a sortable header.
onSort(() => void)NoClick handler for the sort control; only used when the header is sortable.

TableCell

PropTypeRequiredDescription
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, {}>NoAllows 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"Yes
padding"cell" | "trigger"Yes
startIconReactNodeNoLeading element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`.
endIconReactNodeNoTrailing element beside the cell text, e.g. `<Icon icon={...} />` or an `Avatar`.
childrenReactNodeNoCell content.