Reticle@etched/reticle 0.0.0

Table

A data table. Inside it, use the native parts: thead, tbody, tfoot, tr, th and td. Reticle styles them. Columns have fixed widths: set them on the header cells or in a colgroup. A cell with only a Checkbox (with an aria-label) is a check cell. A checked Checkbox anywhere in a row marks the row as selected. Mark the row whose details are open with aria-current="true".

Kumo source
NameStage
Ana RuizInterviewing
José NúñezScheduled
Lee ParkHired
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Table } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function Preview() {
  return (
    <div className="card">
      <Table aria-label="Candidates">
        <thead>
          <tr>
            <th>Name</th>
            <th>Stage</th>
          </tr>
        </thead>
        <tbody>
          {candidates.slice(0, 3).map((person) => (
            <tr key={person.id}>
              <th scope="row">{person.name}</th>
              <td>
                <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Installation

import { Table, TableResizeHandle, TableRow, TableSortHead } from "@etched/reticle/table";

Examples

Check cells, row headings, sorting and pagination

The heading box selects every row, and a checked row shows as selected.

RoleStageOwnerUpdated
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
1–4 of 37
import { useState } from "react";
import { Checkbox } from "@etched/reticle/checkbox";
import { Pagination } from "@etched/reticle/pagination";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Select } from "@etched/reticle/select";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

/** The column headings. Name and Calls sort; `required` keeps one of them sorted at all times. */
function Head({ sort, setSort, required }: { sort: Sort; setSort: (sort: Sort) => void; required?: boolean }) {
  const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
    <TableSortHead
      style={{ inlineSize }}
      sort={sort.column === column ? sort.order : "none"}
      required={required}
      onSortChange={(order) => setSort({ column, order })}
    >
      {label}
    </TableSortHead>
  );

  return (
    <>
      {sortable("name", "Name", "18%")}
      <th>Role</th>
      <th style={{ inlineSize: "12%" }}>Stage</th>
      <th style={{ inlineSize: "11%" }}>Owner</th>
      {sortable("calls", "Calls", "7%")}
      <th style={{ inlineSize: "8%" }}>Updated</th>
    </>
  );
}

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td>{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

export function CheckCellsRowHeadingsSortingAndPagination() {
  const [picked, setPicked] = useState(() => new Set(["jose"]));
  const [sort, setSort] = useState<Sort>({ column: "name", order: "none" });
  const [page, setPage] = useState(1);

  const pick = (id: string, on: boolean) =>
    setPicked((current) => {
      const next = new Set(current);

      if (on) next.add(id);
      else next.delete(id);

      return next;
    });

  return (
    <>
      <div className="card">
        <Table aria-label="Candidates">
          <thead>
            <tr>
              <th>
                <Checkbox
                  aria-label="Select all candidates"
                  checked={picked.size === candidates.length}
                  indeterminate={picked.size > 0 && picked.size < candidates.length}
                  onCheckedChange={(on) => setPicked(new Set(on ? candidates.map((c) => c.id) : []))}
                />
              </th>
              <Head sort={sort} setSort={setSort} />
            </tr>
          </thead>
          <tbody>
            {sorted(candidates, sort).map((person) => (
              <tr key={person.id}>
                <td>
                  <Checkbox aria-label={`Select ${person.name}`} checked={picked.has(person.id)} onCheckedChange={(on) => pick(person.id, on)} />
                </td>
                <Cells person={person} />
              </tr>
            ))}
          </tbody>
        </Table>
      </div>
      <Pagination aria-label="Candidate pages" page={page} setPage={setPage} perPage={4} totalCount={37} />
    </>
  );
}

Dense, always sorted, with the open row marked

Like Orbit's leaderboard: required keeps one column sorted, and aria-current marks the open row.

RoleStageOwnerUpdated
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
import { useState } from "react";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

/** The column headings. Name and Calls sort; `required` keeps one of them sorted at all times. */
function Head({ sort, setSort, required }: { sort: Sort; setSort: (sort: Sort) => void; required?: boolean }) {
  const sortable = (column: Sort["column"], label: string, inlineSize: string) => (
    <TableSortHead
      style={{ inlineSize }}
      sort={sort.column === column ? sort.order : "none"}
      required={required}
      onSortChange={(order) => setSort({ column, order })}
    >
      {label}
    </TableSortHead>
  );

  return (
    <>
      {sortable("name", "Name", "18%")}
      <th>Role</th>
      <th style={{ inlineSize: "12%" }}>Stage</th>
      <th style={{ inlineSize: "11%" }}>Owner</th>
      {sortable("calls", "Calls", "7%")}
      <th style={{ inlineSize: "8%" }}>Updated</th>
    </>
  );
}

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td>{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

export function DenseAlwaysSortedWithTheOpenRowMarked() {
  const [sort, setSort] = useState<Sort>({ column: "calls", order: "descending" });

  return (
    <div className="card">
      <Table aria-label="Recent calls" density="dense">
        <thead>
          <tr>
            <Head sort={sort} setSort={setSort} required />
          </tr>
        </thead>
        <tbody>
          {sorted(candidates, sort).map((person) => (
            <tr key={person.id} aria-current={person.id === "lee" || undefined}>
              <Cells person={person} />
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Compact headings

compactHeader, as in Orbit's trace table: 12 px headings on the raised fill. Point at a heading, or tab to it, to see an unsorted column's arrow and an unused filter; the sort and a filter that is on stay in view.

Role
Stage
OwnerUpdated
Ana RuizASIC design engineerInterviewingSam Lee4Oct 14
José NúñezFirmware engineerScheduledKai Ito12Oct 13
Lee ParkThermal engineerHiredSam Lee11Oct 9
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapWe PassedAna Ruiz1Sep 30
import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Table, TableSortHead, type TableSort } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

interface Sort {
  column: "name" | "calls";
  order: TableSort;
}

const sorted = (rows: Candidate[], { column, order }: Sort) =>
  order === "none"
    ? rows
    : [...rows].sort((a, b) => (order === "ascending" ? 1 : -1) * (column === "name" ? a.name.localeCompare(b.name) : a.calls - b.calls));

function Cells({ person }: { person: Candidate }) {
  return (
    <>
      <th scope="row">{person.name}</th>
      <td>{person.role}</td>
      <td>
        <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
      </td>
      <td>{person.owner}</td>
      <td>{person.calls}</td>
      <td>{person.updated}</td>
    </>
  );
}

const stages: ComboboxItemData[] = [...new Set(candidates.map((person) => person.stage[0]))].map((stage) => ({ value: stage, label: stage }));

export function CompactHeadings() {
  const [sort, setSort] = useState<Sort>({ column: "name", order: "ascending" });
  const [shown, setShown] = useState<string[]>([]);
  const order = (column: Sort["column"]) => (sort.column === column ? sort.order : "none");

  return (
    <div className="card">
      <Table aria-label="Candidates by stage" density="dense" compactHeader>
        <thead>
          <tr>
            <TableSortHead style={{ inlineSize: "18%" }} sort={order("name")} onSortChange={(next) => setSort({ column: "name", order: next })}>
              Name
            </TableSortHead>
            <th>Role</th>
            <th style={{ inlineSize: "14%" }}>
              <div style={{ display: "flex", alignItems: "center", gap: "var(--et-space-2)" }}>
                Stage
                <Combobox multiple appearance="filter" aria-label="Filter by stage" items={stages} value={shown} onValueChange={setShown} />
              </div>
            </th>
            <th style={{ inlineSize: "11%" }}>Owner</th>
            <TableSortHead style={{ inlineSize: "9%" }} sort={order("calls")} onSortChange={(next) => setSort({ column: "calls", order: next })}>
              Calls
            </TableSortHead>
            <th style={{ inlineSize: "8%" }}>Updated</th>
          </tr>
        </thead>
        <tbody>
          {sorted(candidates, sort)
            .filter((person) => !shown.length || shown.includes(person.stage[0]))
            .map((person) => (
              <tr key={person.id}>
                <Cells person={person} />
              </tr>
            ))}
        </tbody>
      </Table>
    </div>
  );
}

Group rows

A heading that spans the row starts each group, as in Orbit's role table.

NameRoleOwner
Silicon
Ana RuizASIC design engineerSam Lee
José NúñezFirmware engineerKai Ito
Systems
Lee ParkThermal engineerSam Lee
Mira OseiPhysical design lead for the next tape-out, a title long enough to wrapAna Ruiz
import { type PillTone } from "@etched/reticle/pill";
import { Table } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const teams: [string, Candidate[]][] = [
  ["Silicon", candidates.slice(0, 2)],
  ["Systems", candidates.slice(2)],
];

export function GroupRows() {
  return (
    <div className="card">
      <Table aria-label="Candidates by team">
        <thead>
          <tr>
            <th style={{ inlineSize: "30%" }}>Name</th>
            <th>Role</th>
            <th style={{ inlineSize: "20%" }}>Owner</th>
          </tr>
        </thead>
        {teams.map(([team, people]) => (
          <tbody key={team}>
            <tr>
              <th scope="rowgroup" colSpan={3}>
                {team}
              </th>
            </tr>
            {people.map((person) => (
              <tr key={person.id}>
                <th scope="row">{person.name}</th>
                <td>{person.role}</td>
                <td>{person.owner}</td>
              </tr>
            ))}
          </tbody>
        ))}
      </Table>
    </div>
  );
}

Pipeline rows

One line per row (truncate) and a sticky first column while the table scrolls sideways. The select-all box sits outside the sort button (start); a checked box anywhere in a row selects it. Ghost triggers edit values in place. Drag a heading's edge, or focus it and use the arrow keys, to resize the column; Home or a double-click resets it.

RoleConvictionStageNotes
Ana Ruiz
InterviewingLed the clock tree rework on the last tape-out; wants to stay close to silicon bring-up.
José Núñez
ScheduledStrong on bootloaders. Asked about the on-call rotation.
Lee Park
HiredBuilt the liquid cooling test bench at their last job.
Mira Osei
We PassedReferred by Sam. Prefers to start after the next quarter.
import { useState } from "react";
import { Avatar } from "@etched/reticle/avatar";
import { Checkbox } from "@etched/reticle/checkbox";
import { Combobox, type ComboboxItemData } from "@etched/reticle/combobox";
import { Pill, type PillTone } from "@etched/reticle/pill";
import { Select, type SelectItem } from "@etched/reticle/select";
import { Text } from "@etched/reticle/text";
import { Table, TableResizeHandle, TableSortHead, type TableSort } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

const roles: ComboboxItemData[] = [
  { value: "asic", label: "ASIC design engineer" },
  { value: "firmware", label: "Firmware engineer" },
  { value: "thermal", label: "Thermal engineer" },
  { value: "physical", label: "Physical design lead" },
];

const convictions: SelectItem<string>[] = [
  { value: "4", label: "Strong yes", tone: "green" },
  { value: "3", label: "Yes", tone: "olive" },
  { value: "2", label: "No", tone: "orange" },
  { value: "1", label: "Strong no", tone: "red" },
];

const notes = new Map([
  ["ana", "Led the clock tree rework on the last tape-out; wants to stay close to silicon bring-up."],
  ["jose", "Strong on bootloaders. Asked about the on-call rotation."],
  ["lee", "Built the liquid cooling test bench at their last job."],
  ["mira", "Referred by Sam. Prefers to start after the next quarter."],
]);

const widths = { candidate: 256, role: 224, conviction: 160, stage: 144 };

export function PipelineRows() {
  const [width, setWidth] = useState(widths);
  const [picked, setPicked] = useState(() => new Set(["lee"]));
  const [order, setOrder] = useState<TableSort>("ascending");
  const [role, setRole] = useState<Record<string, string | null>>({ ana: "asic", jose: "firmware", lee: "thermal", mira: null });
  const [conviction, setConviction] = useState<Record<string, string | null>>({ ana: "4", jose: "3", lee: "4", mira: "2" });

  const pick = (id: string, on: boolean) =>
    setPicked((current) => {
      const next = new Set(current);

      if (on) next.add(id);
      else next.delete(id);

      return next;
    });

  const rows = [...candidates].sort((a, b) => (order === "descending" ? -1 : 1) * a.name.localeCompare(b.name));

  const resize = (column: keyof typeof widths, label: string) => (
    <TableResizeHandle
      aria-label={`${label} column width`}
      width={width[column]}
      defaultWidth={widths[column]}
      onWidthChange={(next) => setWidth({ ...width, [column]: next })}
    />
  );

  return (
    <div className="card">
      <Table aria-label="Pipeline" truncate stickyFirstColumn style={{ minInlineSize: "72rem" }}>
        <thead>
          <tr>
            <TableSortHead
              style={{ inlineSize: width.candidate }}
              sort={order}
              required
              onSortChange={setOrder}
              end={resize("candidate", "Candidate")}
              start={
                <Checkbox
                  aria-label="Select all candidates"
                  checked={picked.size === candidates.length}
                  indeterminate={picked.size > 0 && picked.size < candidates.length}
                  onCheckedChange={(on) => setPicked(new Set(on ? candidates.map((c) => c.id) : []))}
                />
              }
            >
              Candidate
            </TableSortHead>
            <th style={{ inlineSize: width.role }}>
              Role
              {resize("role", "Role")}
            </th>
            <th style={{ inlineSize: width.conviction }}>
              Conviction
              {resize("conviction", "Conviction")}
            </th>
            <th style={{ inlineSize: width.stage }}>
              Stage
              {resize("stage", "Stage")}
            </th>
            <th>Notes</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((person) => (
            <tr key={person.id}>
              <th scope="row">
                <div style={{ display: "flex", alignItems: "center", gap: "var(--et-space-2)" }}>
                  <Checkbox aria-label={`Select ${person.name}`} checked={picked.has(person.id)} onCheckedChange={(on) => pick(person.id, on)} />
                  <Avatar name={person.name} size="sm" alt="" />
                  <Text as="span" truncate>
                    {person.name}
                  </Text>
                </div>
              </th>
              <td>
                <Combobox
                  aria-label={`Role of ${person.name}`}
                  appearance="ghost"
                  size="sm"
                  items={roles}
                  value={role[person.id] ?? null}
                  onValueChange={(value) => setRole({ ...role, [person.id]: value })}
                  placeholder="Add a role"
                />
              </td>
              <td>
                <Select
                  aria-label={`Conviction about ${person.name}`}
                  appearance="ghost"
                  size="sm"
                  items={convictions}
                  value={conviction[person.id] ?? null}
                  onValueChange={(value) => setConviction({ ...conviction, [person.id]: value })}
                  placeholder="Rate"
                />
              </td>
              <td>
                <Pill tone={person.stage[1]}>{person.stage[0]}</Pill>
              </td>
              <td>{notes.get(person.id)}</td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

Rows that open, and sorts in the URL

With onActivate, a click on a row, or Enter or Space on it, opens it. The name link keeps its own click. The sort heading is a link to the next sort.

import { useState } from "react";
import { type PillTone } from "@etched/reticle/pill";
import { Link } from "@etched/reticle/link";
import { Table, TableRow, TableSortHead } from "@etched/reticle/table";

interface Candidate {
  id: string;
  name: string;
  role: string;
  stage: [string, PillTone];
  owner: string;
  calls: number;
  updated: string;
}

const candidates: Candidate[] = [
  { id: "ana", name: "Ana Ruiz", role: "ASIC design engineer", stage: ["Interviewing", "cyan"], owner: "Sam Lee", calls: 4, updated: "Oct 14" },
  { id: "jose", name: "José Núñez", role: "Firmware engineer", stage: ["Scheduled", "purple"], owner: "Kai Ito", calls: 12, updated: "Oct 13" },
  { id: "lee", name: "Lee Park", role: "Thermal engineer", stage: ["Hired", "green"], owner: "Sam Lee", calls: 11, updated: "Oct 9" },
  { id: "mira", name: "Mira Osei", role: "Physical design lead for the next tape-out, a title long enough to wrap", stage: ["We Passed", "red"], owner: "Ana Ruiz", calls: 1, updated: "Sep 30" },
];

export function RowsThatOpenAndSortsInTheUrl() {
  const [open, setOpen] = useState<string | null>(null);

  return (
    <>
      <div className="card">
        <Table aria-label="Leaderboard" density="dense">
          <thead>
            <tr>
              <th>Name</th>
              <TableSortHead sort="descending" render={<a href="#table-sort=calls-ascending" />} style={{ inlineSize: "8rem" }}>
                Calls
              </TableSortHead>
            </tr>
          </thead>
          <tbody>
            {[...candidates]
              .sort((a, b) => b.calls - a.calls)
              .map((person) => (
                <TableRow key={person.id} onActivate={() => setOpen(person.name)} aria-current={open === person.name ? "true" : undefined}>
                  <th scope="row">
                    <Link variant="plain" href={`#profile-${person.id}`}>
                      {person.name}
                    </Link>
                  </th>
                  <td>{person.calls}</td>
                </TableRow>
              ))}
          </tbody>
        </Table>
      </div>
      <p>{open ? `Opened ${open}.` : "No row open."}</p>
    </>
  );
}

API reference

Table

PropTypeDefaultDescription
density"default" | "dense""default"
truncateboolean–Body cells stay on one line and cut long text with an ellipsis; a cell alone in its row (a message or group row) still wraps. Text in a cell's own layout, such as a row with an avatar, needs <Text truncate>.
stickyFirstColumnboolean–The first column stays in place while the table scrolls sideways, with a line at its edge. The table's container must scroll (overflow-x: auto).
compactHeaderboolean–Kumo's compact header: 12 px headings in the text ink on the raised fill. On a device that can hover, an unsorted column's arrow and an unused filter show only while the heading is hovered or holds focus.

TableResizeHandle

PropTypeDefaultDescription
width *number–The column's width now, in pixels.
onWidthChange *(width: number) => void–Called with each new width while the handle moves. The app keeps the width, and stores it if it wants.
defaultWidth *number–The width that Home and a double-click go back to.
minnumber48
maxnumber800
aria-label *string–Names the handle, for example "Candidate column width".

TableRow

PropTypeDefaultDescription
onActivate() => void–Makes the whole row open something: on a click, or on Enter or Space while the row has focus.

TableSortHead

PropTypeDefaultDescription
sort *"ascending" | "descending" | "none"–
requiredboolean–The table always has one sort column: the heading toggles between ascending and descending and never offers none.
startReactNode–Content before the sort button and outside it, such as a select-all Checkbox.
endReactNode–Content after the sort button and outside it, such as a filter.
children *ReactNode–
onSortChange(sort: TableSort) => void–Called on click with the next sort: ascending, descending, then none. Set the other columns to "none".
renderReactElement<{ className?: string }>–A link in place of the sort button, for sorts in the URL: render={<a href="?sort=calls&order=descending" />}. The app points it at the next sort.