---
title: Table
description: Rows of things to scan and open. Inset and rounded, no dividers.
---

```tsx
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](/deft/composed/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                      |

## Links and selection

```tsx
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

```tsx
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

```tsx
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

```tsx
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

```tsx
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

```tsx
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](/deft/components/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

| Prop | Type | Default | Description |
| - | - | - | - |
| `columns` | `string` | - | On Root. grid-template-columns for every row. |
| `compactColumns?` | `string` | - | On Root. Template for a narrow container. |
| `density?` | `"default" \| "compact" \| "dense"` | `"default"` | On Root. |
| `selected?` | `boolean` | - | On Row and ExpandableRow. Tints the row. |
| `interactive?` | `boolean` | - | On Row. Hover fill; on by default when the row has an onClick. |
| `numeric?` | `boolean` | - | On Cell. Right-aligned tabular figures. |
| `align?` | `"start" \| "end"` | `"start"` | On Cell and HeaderCell. |
| `weight?` | `"regular" \| "medium"` | - | On Cell. |
| `truncate?` | `boolean` | - | On Cell. Clips text to one line with an ellipsis. |
| `tone?` | `"default" \| "secondary" \| "muted" \| "icon"` | - | On Cell. |
| `area?` | `"lead" \| "title" \| "aside" \| "meta"` | `"meta"` | On Cell. Position in the stacked layout. |
| `label?` | `ReactNode` | - | On Cell. Prefix shown in the stacked layout. |
| `priority?` | `"high" \| "medium" \| "low"` | - | On Cell and HeaderCell. low hides in a narrow container; medium hides a Cell in the stacked layout. |
| `sort?` | `"ascending" \| "descending"` | - | On HeaderCell, with onSort. |
| `onSort?` | `() => void` | - | On HeaderCell. Makes the header a sort button. |
| `title` | `ReactNode` | - | On Group. |
| `count?` | `ReactNode` | - | On Group. |
| `leading?` | `ReactNode` | - | On Group. Before the title, such as an avatar. |
| `panel` | `ReactNode` | - | On ExpandableRow. Shown under the row. |
| `open?` | `boolean` | - | On Group and ExpandableRow, with onOpenChange. |
| `defaultOpen?` | `boolean` | - | On Group (default true) and ExpandableRow (default false). |
| `onOpenChange?` | `(open: boolean) => void` | - | On Group and ExpandableRow. |
| `rows?` | `number` | `8` | On Loading. Expected initial row count. |
| `render?` | `ReactElement \| function` | - | On RowLink. Renders another element, such as a router link. |

Parts: `Root`, `Body`, `Cell`, `Empty`, `ExpandableRow`, `Group`, `Header`, `HeaderCell`, `Loading`, `Row`, `RowLink`, `SelectCell`. `SelectCell` takes the props of [Checkbox](/deft/components/checkbox) except `size`. `Root`, `Header`, `HeaderCell`, `Body`, `Row` and `Cell` pass other props to their native table elements, and `RowLink` to an `a`.
