Skip to content
OpenPerf
Esc
↑↓navigate↵open⌘Jpreview
On this page

Table

Rows of things to scan and open. Inset and rounded, no dividers.

import { Table } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { ROWS } from "./data";

export default function TableBasic() {
  return (
    <Preview block={true}>
      <Table.Root columns="minmax(0,1fr) 64px">
        <Table.Body>
          {ROWS.map((row) => (
            <Table.Row key={row.id}>
              <Table.Cell>{row.name}</Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </Preview>
  );
}
  • Prefer Data table for lists of records: it builds the grid templates and areas from column definitions. Use the parts directly for rows that do not fit a column model, such as a stack trace with collapsed frame groups.
  • Numbers are right-aligned with numeric, which also sets tabular figures. Times are tone="icon".
  • columns is a CSS grid template shared by the header and every row, so cells line up without a <colgroup>. The table reacts to its container width: compactColumns replaces columns when the container is narrow; pair it with priority="low" on the cells it drops.
  • When the container is narrower still, the header is hidden and rows become two-line cards. area="title" and area="aside" share the first line, meta cells (the default) wrap onto a smaller, muted second line, and lead cells sit in a gutter on the left. label prefixes a cell with its header in this layout; priority="medium" hides a cell only here.
  • Keyboard: ↑ and ↓ move between row links, clickable rows and expand chevrons. Enter follows a row link or runs a row’s onClick; Space toggles the checkbox of a focused clickable row; Enter or Space on a chevron expands its row.
Density Use
default Lists
compact Shorter rows, for breakdowns and trees in detail pages
dense The shortest rows, for waterfalls
import { Table } from "@openperf/deft";
import { useState } from "react";

import { Preview } from "../../src/Preview";
import { ROWS } from "./data";

export default function TableLinksAndSelection() {
  const [selected, setSelected] = useState(["row-1"]);

  return (
    <Preview block={true}>
      <Table.Root columns="16px minmax(0,1fr) 64px">
        <Table.Body>
          {ROWS.map((row) => (
            <Table.Row interactive={true} key={row.id} selected={selected.includes(row.id)}>
              <Table.SelectCell
                aria-label="Select row"
                checked={selected.includes(row.id)}
                onCheckedChange={(checked) => {
                  setSelected(
                    checked ? [...selected, row.id] : selected.filter((id) => id !== row.id),
                  );
                }}
              />
              <Table.Cell>
                <Table.RowLink href={`#${row.id}`}>{row.name}</Table.RowLink>
              </Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </Preview>
  );
}

Rows open with RowLink: the link stretches over the row. Use SelectCell for bulk selection; its checkbox sits above the link, and a click on it never reaches the row, so selecting a row does not open it. selected tints the row.

Grouped rows

import { Table } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { ROWS } from "./data";

export default function TableGroupedRows() {
  return (
    <Preview block={true}>
      <Table.Root columns="minmax(0,1fr) 64px">
        <Table.Group count="2 rows" title="Group 1">
          {ROWS.slice(0, 2).map((row) => (
            <Table.Row key={row.id}>
              <Table.Cell>{row.name}</Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Group>
        <Table.Group count="1 row" title="Group 2">
          {ROWS.slice(2).map((row) => (
            <Table.Row key={row.id}>
              <Table.Cell>{row.name}</Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Group>
      </Table.Root>
    </Preview>
  );
}

Group by a meaningful entity such as a project or service, not by arbitrary chunks of rows. Group gives each entity a collapsible header with an optional count, so readers can scan or collapse one area without losing its context. Groups start open.

Header and sorting

import { Table } from "@openperf/deft";
import { useState } from "react";

import { Preview } from "../../src/Preview";
import { ROWS } from "./data";

export default function TableHeader() {
  const [sort, setSort] = useState<"ascending" | "descending">("descending");

  return (
    <Preview block={true}>
      <Table.Root columns="minmax(0,1fr) 64px">
        <Table.Header>
          <Table.HeaderCell>Name</Table.HeaderCell>
          <Table.HeaderCell
            align="end"
            onSort={() => {
              setSort(sort === "descending" ? "ascending" : "descending");
            }}
            sort={sort}
          >
            Value
          </Table.HeaderCell>
        </Table.Header>
        <Table.Body>
          {ROWS.toSorted((a, b) =>
            sort === "descending" ? b.value - a.value : a.value - b.value,
          ).map((row) => (
            <Table.Row key={row.id}>
              <Table.Cell>{row.name}</Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </Preview>
  );
}

Use sort to show the active direction and reorder the data in onSort; the header does not sort rows by itself. onSort turns the header label into a button, and sort sets aria-sort. Give numeric headers align="end" and their cells numeric so labels and values share the same right edge.

Expandable rows

import { Table } from "@openperf/deft";

import { Preview } from "../../src/Preview";
import { ROWS } from "./data";

export default function TableExpandable() {
  return (
    <Preview block={true}>
      <Table.Root columns="16px minmax(0,1fr) 64px">
        <Table.Body>
          {ROWS.map((row, index) => (
            <Table.ExpandableRow defaultOpen={index === 0} key={row.id} panel="Details">
              <Table.Cell>{row.name}</Table.Cell>
              <Table.Cell numeric={true}>{row.value}</Table.Cell>
            </Table.ExpandableRow>
          ))}
        </Table.Body>
      </Table.Root>
    </Preview>
  );
}

ExpandableRow adds a chevron as its first cell, so the first column of an expandable table is a narrow one for it. The panel sits under the row on the same fill and holds secondary detail or actions. Rows start closed. When the row has a destination, keep RowLink as its primary navigation rather than making expansion the only way forward.

Loading

import { Table } from "@openperf/deft";

import { Preview } from "../../src/Preview";

export default function TableLoading() {
  return (
    <Preview block={true}>
      <Table.Root columns="minmax(0,1fr) 64px">
        <Table.Header>
          <Table.HeaderCell>Name</Table.HeaderCell>
          <Table.HeaderCell align="end">Value</Table.HeaderCell>
        </Table.Header>
        <Table.Loading rows={3} />
      </Table.Root>
    </Preview>
  );
}

During the initial load, set rows to the number of rows the first response is expected to contain. Keep the final columns and header in place so loading does not change the table’s geometry. The body sets aria-busy.

Empty

import { Empty, Table } from "@openperf/deft";

import { Preview } from "../../src/Preview";

export default function TableEmpty() {
  return (
    <Preview block={true}>
      <Table.Root columns="minmax(0,1fr) 64px">
        <Table.Header>
          <Table.HeaderCell>Name</Table.HeaderCell>
          <Table.HeaderCell align="end">Value</Table.HeaderCell>
        </Table.Header>
        <Table.Empty>
          <Empty.Title>Title</Empty.Title>
          <Empty.Description>Description</Empty.Description>
        </Table.Empty>
      </Table.Root>
    </Preview>
  );
}

Use the empty state after a successful response returns no rows, not for a failed request. It takes the parts of Empty. Name the active period or filter and offer a useful next step, such as clearing the filter or widening the period. Faint placeholder rows sit behind the message so the table keeps its shape; they never animate, so they cannot be mistaken for loading.

API

PropType
columnsstring

On Root. grid-template-columns for every row.

Typestring
compactColumns?string

On Root. Template for a narrow container.

Typestring
density?"default" | "compact" | "dense"

On Root.

Type"default" | "compact" | "dense"
Default"default"
selected?boolean

On Row and ExpandableRow. Tints the row.

Typeboolean
interactive?boolean

On Row. Hover fill; on by default when the row has an onClick.

Typeboolean
numeric?boolean

On Cell. Right-aligned tabular figures.

Typeboolean
align?"start" | "end"

On Cell and HeaderCell.

Type"start" | "end"
Default"start"
weight?"regular" | "medium"

On Cell.

Type"regular" | "medium"
truncate?boolean

On Cell. Clips text to one line with an ellipsis.

Typeboolean
tone?"default" | "secondary" | "muted" | "icon"

On Cell.

Type"default" | "secondary" | "muted" | "icon"
area?"lead" | "title" | "aside" | "meta"

On Cell. Position in the stacked layout.

Type"lead" | "title" | "aside" | "meta"
Default"meta"
label?ReactNode

On Cell. Prefix shown in the stacked layout.

TypeReactNode
priority?"high" | "medium" | "low"

On Cell and HeaderCell. low hides in a narrow container; medium hides a Cell in the stacked layout.

Type"high" | "medium" | "low"
sort?"ascending" | "descending"

On HeaderCell, with onSort.

Type"ascending" | "descending"
onSort?() => void

On HeaderCell. Makes the header a sort button.

Type() => void
titleReactNode

On Group.

TypeReactNode
count?ReactNode

On Group.

TypeReactNode
leading?ReactNode

On Group. Before the title, such as an avatar.

TypeReactNode
panelReactNode

On ExpandableRow. Shown under the row.

TypeReactNode
open?boolean

On Group and ExpandableRow, with onOpenChange.

Typeboolean
defaultOpen?boolean

On Group (default true) and ExpandableRow (default false).

Typeboolean
onOpenChange?(open: boolean) => void

On Group and ExpandableRow.

Type(open: boolean) => void
rows?number

On Loading. Expected initial row count.

Typenumber
Default8
render?ReactElement | function

On RowLink. Renders another element, such as a router link.

TypeReactElement | function

Parts: Root, Body, Cell, Empty, ExpandableRow, Group, Header, HeaderCell, Loading, Row, RowLink, SelectCell. SelectCell takes the props of Checkbox except size. Root, Header, HeaderCell, Body, Row and Cell pass other props to their native table elements, and RowLink to an a.

Was this page helpful?