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 aretone="icon". columnsis 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:compactColumnsreplacescolumnswhen the container is narrow; pair it withpriority="low"on the cells it drops.- When the container is narrower still, the header is hidden and rows become two-line cards.
area="title"andarea="aside"share the first line,metacells (the default) wrap onto a smaller, muted second line, andleadcells sit in a gutter on the left.labelprefixes 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 |
Links and selection
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
columnsstring
On Root. grid-template-columns for every row.
stringcompactColumns?string
On Root. Template for a narrow container.
stringdensity?"default" | "compact" | "dense"
On Root.
"default" | "compact" | "dense""default"selected?boolean
On Row and ExpandableRow. Tints the row.
booleaninteractive?boolean
On Row. Hover fill; on by default when the row has an onClick.
booleannumeric?boolean
On Cell. Right-aligned tabular figures.
booleanalign?"start" | "end"
On Cell and HeaderCell.
"start" | "end""start"weight?"regular" | "medium"
On Cell.
"regular" | "medium"truncate?boolean
On Cell. Clips text to one line with an ellipsis.
booleantone?"default" | "secondary" | "muted" | "icon"
On Cell.
"default" | "secondary" | "muted" | "icon"area?"lead" | "title" | "aside" | "meta"
On Cell. Position in the stacked layout.
"lead" | "title" | "aside" | "meta""meta"label?ReactNode
On Cell. Prefix shown in the stacked layout.
ReactNodepriority?"high" | "medium" | "low"
On Cell and HeaderCell. low hides in a narrow container; medium hides a Cell in the stacked layout.
"high" | "medium" | "low"sort?"ascending" | "descending"
On HeaderCell, with onSort.
"ascending" | "descending"onSort?() => void
On HeaderCell. Makes the header a sort button.
() => voidtitleReactNode
On Group.
ReactNodecount?ReactNode
On Group.
ReactNodeleading?ReactNode
On Group. Before the title, such as an avatar.
ReactNodepanelReactNode
On ExpandableRow. Shown under the row.
ReactNodeopen?boolean
On Group and ExpandableRow, with onOpenChange.
booleandefaultOpen?boolean
On Group (default true) and ExpandableRow (default false).
booleanonOpenChange?(open: boolean) => void
On Group and ExpandableRow.
(open: boolean) => voidrows?number
On Loading. Expected initial row count.
number8render?ReactElement | function
On RowLink. Renders another element, such as a router link.
ReactElement | functionParts: 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.