# Table

Rows of data that compare along columns: members, invoices, deployments. Rows run edge to edge with a divider under each, header text is secondary, and a body row takes the neutral hover overlay. Cells hold any component: an Avatar and a name, a Badge, a Checkbox to pick the row, a DropdownMenu of row actions. Headers can sort, a TableToolbar above and below holds search, actions, the count and Pagination, and TableEmpty says when there are no rows. TableSelection adds row picking: a checkbox per row, one in the header that follows them, and a bar that counts and names the picked rows.

## Usage

### When to use

- Rows of data the user compares along columns: members, invoices, deployments.
- Numbers that line up down a column, such as amounts or counts.
- Rows the user picks and then acts on together, such as removing several members, with TableSelection.

### When not to use

- A single list of items with one or two lines each. Use a list of rows instead.
- A grid of cards, such as projects with images. Use Card.
- Layout. Use flex or grid classes.

## Variants

### align

Set on TableHead and TableCell. Where the content sits in the cell. end for numbers, center only for short status marks.

Values: `start`, `center`, `end`. Default: `start`.

### sortDirection

Set on a sortable TableHead. How the table is sorted by this column; sets aria-sort and the caret.

Values: `none`, `ascending`, `descending`. Default: `none`.

## Examples

### Invoices

```tsx
<Table>
  <TableCaption>Invoices for 2026.</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead>Invoice</TableHead>
      <TableHead>Status</TableHead>
      <TableHead numeric>Amount</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>INV-001</TableCell>
      <TableCell>
        <Badge variant="success">Paid</Badge>
      </TableCell>
      <TableCell numeric>$250.00</TableCell>
    </TableRow>
    <TableRow>
      <TableCell>INV-002</TableCell>
      <TableCell>
        <Badge variant="warning">Due</Badge>
      </TableCell>
      <TableCell numeric>$1,200.00</TableCell>
    </TableRow>
  </TableBody>
  <TableFooter>
    <TableRow>
      <TableCell>Total</TableCell>
      <TableCell />
      <TableCell numeric>$1,450.00</TableCell>
    </TableRow>
  </TableFooter>
</Table>
```

### Members

Rows picked with TableSelection: the header checkbox follows the rows, and the bar above counts and names the picked ones, with the actions on them. Search and an action above the table, a sortable name, an Avatar and an email in one cell, a Badge, row actions at the end, the count with Pagination below.

```tsx
<TableSelection defaultValue={["ada"]}>
  <TableSelectionBar>
    <Button variant="secondary" size="sm">Export</Button>
    <Button variant="destructive-soft" size="sm">Remove</Button>
  </TableSelectionBar>
  <TableToolbar>
    <Input aria-label="Search members" placeholder="Search members" leadingIcon={<IconSearch size={16} />} className="max-w-xs" />
    <TableActions>
      <Button>Invite</Button>
    </TableActions>
  </TableToolbar>
  <Table aria-label="Members">
    <TableHeader>
      <TableRow>
        <TableHead fit>
          <TableSelectAll aria-label="Select all members" />
        </TableHead>
        <TableHead sortDirection="ascending" onSort={sortBy}>Name</TableHead>
        <TableHead>Role</TableHead>
        <TableHead fit>
          <span className="sr-only">Actions</span>
        </TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="ada" label="Ada Lovelace" />
        </TableCell>
        <TableCell>
          <div className="flex items-center gap-sm">
            <Avatar alt="" color="blue">AL</Avatar>
            <div className="flex flex-col">
              <span className="font-medium">Ada Lovelace</span>
              <span className="text-sm text-text-secondary">ada@example.com</span>
            </div>
          </div>
        </TableCell>
        <TableCell>
          <Badge>Owner</Badge>
        </TableCell>
        <TableCell fit>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="sm" iconOnly aria-label="Actions for Ada Lovelace">
                <IconDots size={16} />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem>Change role</DropdownMenuItem>
              <DropdownMenuItem destructive>Remove from team</DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="grace" label="Grace Hopper" />
        </TableCell>
        <TableCell>
          <div className="flex items-center gap-sm">
            <Avatar alt="" color="green">GH</Avatar>
            <div className="flex flex-col">
              <span className="font-medium">Grace Hopper</span>
              <span className="text-sm text-text-secondary">grace@example.com</span>
            </div>
          </div>
        </TableCell>
        <TableCell>
          <Badge variant="outline">Editor</Badge>
        </TableCell>
        <TableCell fit>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="sm" iconOnly aria-label="Actions for Grace Hopper">
                <IconDots size={16} />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem>Change role</DropdownMenuItem>
              <DropdownMenuItem destructive>Remove from team</DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </TableCell>
      </TableRow>
    </TableBody>
  </Table>
  <TableToolbar>
    <span className="text-sm text-text-secondary">2 of 24 members</span>
    <TableActions>
      <Pagination page={1} pageCount={12} onPageChange={goToPage} />
    </TableActions>
  </TableToolbar>
</TableSelection>
```

### Many rows picked

Past three names the bar shows the rest as a count. max on TableSelectionBar changes how many names show.

```tsx
<TableSelection defaultValue={["ada", "grace", "alan", "katherine", "linus"]}>
  <TableSelectionBar>
    <Button variant="destructive-soft" size="sm">Remove</Button>
  </TableSelectionBar>
  <Table aria-label="Members">
    <TableHeader>
      <TableRow>
        <TableHead fit>
          <TableSelectAll aria-label="Select all members" />
        </TableHead>
        <TableHead>Name</TableHead>
        <TableHead>Role</TableHead>
      </TableRow>
    </TableHeader>
    <TableBody>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="ada" label="Ada Lovelace" />
        </TableCell>
        <TableCell>Ada Lovelace</TableCell>
        <TableCell>Owner</TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="grace" label="Grace Hopper" />
        </TableCell>
        <TableCell>Grace Hopper</TableCell>
        <TableCell>Editor</TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="alan" label="Alan Turing" />
        </TableCell>
        <TableCell>Alan Turing</TableCell>
        <TableCell>Editor</TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="katherine" label="Katherine Johnson" />
        </TableCell>
        <TableCell>Katherine Johnson</TableCell>
        <TableCell>Viewer</TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="linus" label="Linus Torvalds" />
        </TableCell>
        <TableCell>Linus Torvalds</TableCell>
        <TableCell>Viewer</TableCell>
      </TableRow>
      <TableRow>
        <TableCell fit>
          <TableRowSelect value="margaret" label="Margaret Hamilton" />
        </TableCell>
        <TableCell>Margaret Hamilton</TableCell>
        <TableCell>Viewer</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</TableSelection>
```

### No results

```tsx
<Table aria-label="Members">
  <TableHeader>
    <TableRow>
      <TableHead>Name</TableHead>
      <TableHead>Role</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableEmpty colSpan={2}>No members match your search.</TableEmpty>
  </TableBody>
</Table>
```

### Next page loading

The user asked for page 2, which is still loading: page 1 stays, dimmed and marked busy. In an app, pending is the isPending of the transition that changes the page.

```tsx
<Table pending pendingLabel="Loading page 2" aria-label="Invoices">
  <TableHeader>
    <TableRow>
      <TableHead>Invoice</TableHead>
      <TableHead numeric>Amount</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>INV-001</TableCell>
      <TableCell numeric>$250.00</TableCell>
    </TableRow>
    <TableRow>
      <TableCell>INV-002</TableCell>
      <TableCell numeric>$150.00</TableCell>
    </TableRow>
  </TableBody>
</Table>
```

## Do and don't

### Do

- Use numeric on every cell of a number column, the header too.
- Put the table on a surface with its own edge (a Card, a page section); the table has none.
- Name it with a caption, or aria-label when a heading above says what it is.
- Put search and the main action in a TableToolbar above the table, and the count with Pagination in one below.
- Use fit for a Checkbox column and an actions column, so the data columns get the width.
- For rows the user picks and then acts on together, wrap the table in TableSelection; it keeps the header checkbox, the rows and the bar in step.
- Put the actions on the picked rows (Export, Delete) in TableSelectionBar, as sm buttons.
- When a page, sort or filter loads from the server, change it in a transition (useTransition) and pass isPending as pending: the current rows stay until the next ones arrive. Use Skeleton rows only for the first load.

### Don't

- Don't wrap each row in a Card; rows are the layout.
- Don't center text columns; start-align them.
- Don't use a table for layout or for a single list of items.
- Don't wire Checkbox cells by hand when TableSelection fits; the header checkbox and the bar drift out of step.
- Don't empty the table or swap the rows for a spinner while the next page loads; the layout jumps and the user loses their place.

## Props

| Name | Type | Values | Default | Description |
| --- | --- | --- | --- | --- |
| `align` | `enum` | `start`, `center`, `end` | `start` | Set on TableHead and TableCell. Where the content sits in the cell. end for numbers, center only for short status marks. |
| `numeric` | `boolean` |  | `false` | Set on TableHead and TableCell. A number column: end-aligned, mono, tabular figures, so digits line up. |
| `fit` | `boolean` |  | `false` | Set on TableHead and TableCell. The column takes only the room its content needs: a Checkbox, a row's actions. |
| `sortDirection` | `enum` | `none`, `ascending`, `descending` | `none` | Set on a sortable TableHead. How the table is sorted by this column; sets aria-sort and the caret. |
| `onSort` | `function` |  |  | Set on TableHead. Makes the header a button that sorts by the column. Sort the rows yourself, then set sortDirection. |
| `selected` | `boolean` |  | `false` | Set on TableRow. The row is picked with its Checkbox: the muted accent fill. |
| `colSpan` | `number` |  |  | Set on TableEmpty. How many columns the table has, so the row spans them all. |
| `defaultValue` | `string` |  |  | Set on TableSelection. Values of the rows picked on first render, as an array: ["ada"]. Each value is a TableRowSelect's value. |
| `value` | `string` |  |  | Set on TableSelection: the picked values as an array, when you control them. Pair it with onValueChange. TableRowSelect takes one value, its row's id. |
| `onValueChange` | `function` |  |  | Set on TableSelection. Called with the new array of picked values. |
| `label` | `string` |  |  | Set on TableRowSelect, with its value. The row's name: TableSelectionBar shows it, and it names the checkbox "Select <label>". |
| `max` | `number` |  | `3` | Set on TableSelectionBar. How many names show before the rest becomes +N. |
| `pending` | `boolean` |  | `false` | Table: the next page, sort or filter is still loading. Change it in a transition (useTransition) so the current rows stay, and pass its isPending: the rows dim to opacity-pending and the table is marked aria-busy instead of going blank. |
| `pendingLabel` | `string` |  | `Loading` | Table: read by screen readers in a polite status while pending. |

### Slots

| Name | Required | Description |
| --- | --- | --- |
| `toolbar` | no | TableToolbar above the table (search, filters, TableActions with buttons) and one below it (the count, TableActions with Pagination). |
| `header` | no | TableHeader with one TableRow of TableHead cells. |
| `body` | yes | TableBody with one TableRow per item. |
| `caption` | no | TableCaption, or aria-label on Table. |
| `cells` | no | Any component in a TableCell: Avatar with a name, Badge, Checkbox in a fit cell, a DropdownMenu of row actions in a fit cell at the end. |
| `empty` | no | TableEmpty in TableBody when there are no rows. |
| `selection` | no | TableSelection around the bar, the toolbars and the table, with TableSelectAll in the header's first fit cell, a TableRowSelect in each row's, and TableSelectionBar above the toolbar. Children of the bar are the app's actions on the picked rows (Export, Delete). |

## Anatomy

| Part | Description |
| --- | --- |
| `root` | Table: a full-width table, 14px primary text, inside a container that scrolls sideways. |
| `header` | TableHeader: the header rows. They don't highlight on hover. |
| `row` | TableRow: a row with a divider under it and the neutral hover overlay. The last body row has no divider. selected fills it with the muted accent and its strong text color. |
| `head` | TableHead: a header cell, the md control height, 13px medium secondary text on one line. |
| `cell` | TableCell: a data cell, px-md py-sm, that holds text or any component. align sets start, center or end; numeric sets mono, tabular figures and end; fit shrinks the column to its content. |
| `footer` | TableFooter: totals on the sunken fill, medium text, a divider above. |
| `caption` | TableCaption: the name of the table, under it, 13px secondary text. |
| `sort` | With onSort: a button in the header cell with the header's text and a caret, primary text on hover, a size-target-min hit area. |
| `icon` | The sort caret: Tabler ChevronUpDown, CaretUp or CaretDown, 16px, secondary. |
| `empty` | TableEmpty: one row across colSpan columns, 13px secondary text, centered, py-2xl. |
| `toolbar` | TableToolbar: a row above or below the table, gap-sm, that wraps: a search Input, filters, the count of results. |
| `actions` | TableActions: the end of a toolbar, gap-sm: buttons that act on the table, or Pagination. |
| `selection` | TableSelection: a column (gap-md) around the bar, the toolbars and the table. Holds the picked rows and announces their count in a status region. |
| `selectAll` | TableSelectAll: the Checkbox in the header's fit cell. On when every row on screen is picked, partly on when some are. |
| `rowSelect` | TableRowSelect: the Checkbox in a row's fit cell, named "Select <label>". A checked one paints its row with the muted accent. |
| `selectionBar` | TableSelectionBar: shows only while a row is picked. The count, up to max names then +N, Select all, the app's actions, Clear selection. Border, radius-container, surface fill. |
| `body` | The body rows. With pending they stay on screen and dim to opacity-pending (duration-medium); the container is marked aria-busy, with a polite status next to it. |

## Accessibility

Role: `table`

| Keys | Action |
| --- | --- |
| `Tab` | Moves through the sort buttons in the headers and the controls inside cells; the table itself is not focusable. |
| `Enter, Space` | On a sortable header, sorts by that column. |

- A native table: screen readers read each cell with its column header. TableHead sets scope="col" by default; set scope="row" on a header cell that names its row.
- Name the table with a TableCaption, or aria-label or aria-labelledby when the name is a heading above it.
- A wide table scrolls sideways inside its container instead of widening the page.
- A sortable header is a button inside the header cell; the cell carries aria-sort (ascending or descending) only for the column the table is sorted by.
- Pick rows with a Checkbox in a fit cell, named for its row (Select Ada Lovelace); a header Checkbox picks every row. selected only paints the row: the Checkbox tells screen readers.
- Give an icon-only actions button a name with the row in it: Actions for Ada Lovelace.
- TableEmpty is one row across every column, so the table still has its headers when nothing matches.
- TableSelectAll is a mixed checkbox: aria-checked is mixed while some rows are picked. A press then picks the rest; a press when all are picked clears them.
- TableSelection keeps a status region that reads the count ("2 of 5 rows selected") whenever it changes.
- Select all and Clear selection go away once pressed, so focus moves first: to Clear selection after Select all, to the header checkbox after Clear selection.

## Install

```bash
npx shadcn add https://werewolf-ui.vercel.app/r/table.json
```

This also adds `styles/werewolf.css`, the tokens every component uses, and any other component it needs. Import that file once in your root CSS, after `@import "tailwindcss"`, with a path relative to that CSS file (from `app/globals.css` it is `@import "../styles/werewolf.css"`).
