# Table

Display structured data with keyboard navigation, optional sorting, and row selection.

Project overview

[Complete TSX file](/examples/table/default.tsx)

```tsx
"use client";

import { Badge } from "@/siteplane/components/badge";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/siteplane/components/table";

export default function Example() {
  return (
    <Table aria-label="Projects">
      <TableHeader>
        <TableHead id="project" isRowHeader>
          Project
        </TableHead>
        <TableHead id="status">Status</TableHead>
        <TableHead id="team">Team</TableHead>
        <TableHead id="budget" align="end">
          Budget
        </TableHead>
      </TableHeader>
      <TableBody>
        <TableRow id="website-redesign" textValue="Website Redesign">
          <TableCell>Website Redesign</TableCell>
          <TableCell>
            <Badge variant="success">Paid</Badge>
          </TableCell>
          <TableCell>Frontend Team</TableCell>
          <TableCell align="end">$12,500</TableCell>
        </TableRow>
        <TableRow id="mobile-app" textValue="Mobile App">
          <TableCell>Mobile App</TableCell>
          <TableCell>
            <Badge variant="secondary">Unpaid</Badge>
          </TableCell>
          <TableCell>Mobile Team</TableCell>
          <TableCell align="end">$8,750</TableCell>
        </TableRow>
        <TableRow id="api-integration" textValue="API Integration">
          <TableCell>API Integration</TableCell>
          <TableCell>
            <Badge variant="warning">Pending</Badge>
          </TableCell>
          <TableCell>Backend Team</TableCell>
          <TableCell align="end">$5,200</TableCell>
        </TableRow>
        <TableRow id="security-audit" textValue="Security Audit">
          <TableCell>Security Audit</TableCell>
          <TableCell>
            <Badge variant="error">Failed</Badge>
          </TableCell>
          <TableCell>Security Team</TableCell>
          <TableCell align="end">$2,100</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/table
```

With npm: `npx shadcn@4.18.0 add @siteplane/table`. [Registry JSON](/registry/table.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/badge @siteplane/button @siteplane/card @siteplane/menu
```

Includes `react-aria-components`. Import `Selection` and `Key` from that package; `SortDescriptor` is also exported by Siteplane UI's Table module.

## Usage

```tsx
import {
  Table,
  TableHeader,
  TableHead,
  TableBody,
  TableRow,
  TableCell,
} from "@/siteplane/components/table";

<Table aria-label="Projects">
  <TableHeader>
    <TableHead id="project" isRowHeader>
      Project
    </TableHead>
    <TableHead id="team">Team</TableHead>
  </TableHeader>
  <TableBody>
    <TableRow id="website-redesign" textValue="Website Redesign">
      <TableCell>Website Redesign</TableCell>
      <TableCell>Frontend Team</TableCell>
    </TableRow>
  </TableBody>
</Table>;
```

## Composition

```text
Table
├── TableCaption (optional)
├── TableHeader
│   └── TableHead
├── TableBody
│   └── TableRow
│       └── TableCell
└── TableFooter (optional)
    └── TableRow
        └── TableCell
```

`TableHead` is a direct child of `TableHeader`; do not insert a header `TableRow`. Use stable row and column IDs, mark an identifying column with `isRowHeader`, and provide a plain-text `textValue` for rows with rich content. These are React Aria collection parts.

## Sorting

`allowsSorting` enables the column control. Your application handles `onSortChange`, updates `sortDescriptor`, and reorders the data.

Sortable columns

[Complete TSX file](/examples/table/sortable.tsx)

```tsx
"use client";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  type SortDescriptor,
} from "@/siteplane/components/table";
import { useState } from "react";

const projects = [
  {
    id: "website-redesign",
    project: "Website Redesign",
    role: "Owner",
    team: "Frontend Team",
    budget: "$12,500",
  },
  {
    id: "mobile-app",
    project: "Mobile App",
    role: "Editor",
    team: "Mobile Team",
    budget: "$8,750",
  },
  {
    id: "api-integration",
    project: "API Integration",
    role: "Editor",
    team: "Backend Team",
    budget: "$5,200",
  },
];

export default function Example() {
  const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>();
  const sortedProjects = sortDescriptor
    ? [...projects].sort((left, right) => {
        const order = left.project.localeCompare(right.project, "en");
        return sortDescriptor.direction === "descending" ? -order : order;
      })
    : projects;
  return (
    <Table
      aria-label="Projects"
      sortDescriptor={sortDescriptor}
      onSortChange={setSortDescriptor}
    >
      <TableHeader>
        <TableHead id="project" isRowHeader allowsSorting>
          Project
        </TableHead>
        <TableHead id="role">Role</TableHead>
        <TableHead id="team">Team</TableHead>
        <TableHead id="budget" align="end">
          Budget
        </TableHead>
      </TableHeader>
      <TableBody>
        {sortedProjects.map((project) => (
          <TableRow
            key={project.id}
            id={project.id}
            textValue={project.project}
          >
            <TableCell>{project.project}</TableCell>
            <TableCell>{project.role}</TableCell>
            <TableCell>{project.team}</TableCell>
            <TableCell align="end">{project.budget}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

Use the appropriate comparison for each column: numbers for budgets, locale-aware text for names, and dates for date columns. Preserve row IDs when reordering.

## Row selection

Selection is off by default. Use `selectionMode="multiple"` with `selectedKeys` and `onSelectionChange`, and set `selectionTrigger="checkbox"` so only `TableSelectionCheckbox` changes the selection; the header checkbox selects all available rows and shows a partial selection as indeterminate.

Checkbox selection

[Complete TSX file](/examples/table/checkbox-selection.tsx)

```tsx
"use client";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableSelectionCheckbox,
} from "@/siteplane/components/table";
import { useState } from "react";
import type { Selection } from "react-aria-components";

const projects = [
  {
    id: "website-redesign",
    project: "Website Redesign",
    role: "Owner",
    team: "Frontend Team",
    budget: "$12,500",
  },
  {
    id: "mobile-app",
    project: "Mobile App",
    role: "Editor",
    team: "Mobile Team",
    budget: "$8,750",
  },
  {
    id: "api-integration",
    project: "API Integration",
    role: "Editor",
    team: "Backend Team",
    budget: "$5,200",
  },
];

export default function Example() {
  const [selectedKeys, setSelectedKeys] = useState<Selection>(
    new Set(["website-redesign"]),
  );
  return (
    <Table
      aria-label="Projects"
      selectionMode="multiple"
      selectionTrigger="checkbox"
      selectedKeys={selectedKeys}
      onSelectionChange={setSelectedKeys}
    >
      <TableHeader>
        <TableHead id="selection">
          <TableSelectionCheckbox />
        </TableHead>
        <TableHead id="project" isRowHeader>
          Project
        </TableHead>
        <TableHead id="role">Role</TableHead>
        <TableHead id="team">Team</TableHead>
        <TableHead id="budget" align="end">
          Budget
        </TableHead>
      </TableHeader>
      <TableBody>
        {projects.map((project) => (
          <TableRow
            key={project.id}
            id={project.id}
            textValue={project.project}
          >
            <TableCell>
              <TableSelectionCheckbox />
            </TableCell>
            <TableCell>{project.project}</TableCell>
            <TableCell>{project.role}</TableCell>
            <TableCell>{project.team}</TableCell>
            <TableCell align="end">{project.budget}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

The check of a selected row draws in when the row is selected and out when it is not, the way the [Checkbox](/docs/components/checkbox/) check does, and so does the dash of a partial selection; both appear instantly under reduced motion. The check and the dash take the text color for accent fills, `--sp-rw-fg-on-accent`, which a light brand sets dark ([Theming](/docs/customization/theming/#override-component-colors)).

`Selection` can be a key set or `"all"`; handle both. For remote or paginated data, define the scope of “all” before bulk actions. With the default `selectionTrigger="row"`, clicking a row also selects it. Use `selectionMode="single"` for one row and `disallowEmptySelection` to prevent clearing an existing selection.

## Row actions

Place a Menu in the actions column for row-specific commands. Use `onAction` for whole-row activation and `href` for navigation.

Project row actions

[Complete TSX file](/examples/table/row-action.tsx)

```tsx
"use client";

import { CopyIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/siteplane/components/button";
import {
  Menu,
  MenuItem,
  MenuPopup,
  MenuSeparator,
  MenuTrigger,
} from "@/siteplane/components/menu";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/siteplane/components/table";

const projects = [
  { id: "website", name: "Website Redesign", team: "Frontend Team" },
  { id: "mobile", name: "Mobile App", team: "Mobile Team" },
  { id: "api", name: "API Integration", team: "Backend Team" },
];

export default function Example() {
  return (
    <Table aria-label="Project actions">
      <TableHeader>
        <TableHead id="project" isRowHeader>
          Project
        </TableHead>
        <TableHead id="team">Team</TableHead>
        <TableHead id="actions" align="end">
          Actions
        </TableHead>
      </TableHeader>
      <TableBody>
        {projects.map((project) => (
          <TableRow key={project.id} id={project.id} textValue={project.name}>
            <TableCell>{project.name}</TableCell>
            <TableCell>{project.team}</TableCell>
            <TableCell align="end">
              <Menu>
                <MenuTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-md"
                      aria-label={`Actions for ${project.name}`}
                    />
                  }
                >
                  <EllipsisIcon />
                </MenuTrigger>
                <MenuPopup align="end">
                  <MenuItem>
                    <PencilIcon />
                    Edit project
                  </MenuItem>
                  <MenuItem>
                    <CopyIcon />
                    Duplicate
                  </MenuItem>
                  <MenuSeparator />
                  <MenuItem variant="destructive">
                    <Trash2Icon />
                    Delete
                  </MenuItem>
                </MenuPopup>
              </Menu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

Without selection, a click or Enter activates the row. When the table also allows selection, `selectionBehavior` decides what a click does. With `toggle`, the default, a click or Enter runs the row's action while no row is selected; once rows are selected, a click toggles the selection of the row instead. With `replace`, one click selects a row and a double click runs its action. With `selectionTrigger="checkbox"`, only the checkboxes change the selection, so a click, a tap or Enter on the row runs its `onAction` whether rows are selected or not, and `selectionBehavior` does not apply. Space stays the selection key and runs no action. Links retain their navigation behavior; under `selectionTrigger="checkbox"`, a row link navigates while no row is selected, so give such rows an `onAction` that navigates if they should also open while rows are selected.

A control inside a row keeps its own presses. A click on a menu trigger, a button or a link in a cell, and Enter or Space while it has focus, reach only that control: they neither run the row's action nor change the selection. This holds for React Aria controls, which stop their presses themselves, and for every other control, such as a Siteplane UI Menu trigger.

A root `onRowAction(key)` is also available. Use row-level `onAction` when the row should show Siteplane UI's interactive styling and pointer cursor. For rows in `disabledKeys`, `disabledBehavior="all"` prevents focus, selection and actions, while `"selection"` only prevents selection. `inactive` only dims a row; it does not restrict interaction.

## Empty collection

Use `TableBody renderEmptyState` for an empty collection. The table keeps its column headings while the body displays the empty state. A select-all checkbox in the headings is unchecked and disabled while the table has no rows, also when keys of rows that a filter removed stay selected.

Empty collection

[Complete TSX file](/examples/table/empty.tsx)

```tsx
"use client";

import {
  Table,
  TableBody,
  TableHead,
  TableHeader,
} from "@/siteplane/components/table";

export default function Example() {
  return (
    <Table aria-label="Projects">
      <TableHeader>
        <TableHead id="project" isRowHeader>
          Project
        </TableHead>
        <TableHead id="team">Team</TableHead>
      </TableHeader>
      <TableBody
        renderEmptyState={() => (
          <p className="p-6 text-center text-muted-foreground">
            No projects found.
          </p>
        )}
      >
        {[]}
      </TableBody>
    </Table>
  );
}
```

Table has no `loading` prop. Set `aria-busy` on the surrounding results region while loading, and show a distinct error/retry state for failed requests.

## Caption and footer

Place `TableCaption` directly inside `Table`; it appears below the table and supplies its accessible name unless you provide `aria-labelledby`. Use `TableFooter` for summary rows and `colSpan` for labels that span columns; your application calculates the totals.

Project budgets with caption and total

[Complete TSX file](/examples/table/footer-summary.tsx)

```tsx
"use client";

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/siteplane/components/table";

export default function Example() {
  return (
    <Table>
      <TableCaption>Current project budgets and team access.</TableCaption>
      <TableHeader>
        <TableHead id="project" isRowHeader>
          Project
        </TableHead>
        <TableHead id="role">Role</TableHead>
        <TableHead id="team">Team</TableHead>
        <TableHead id="budget" align="end">
          Budget
        </TableHead>
      </TableHeader>
      <TableBody>
        <TableRow id="website-redesign" textValue="Website Redesign">
          <TableCell>Website Redesign</TableCell>
          <TableCell>Owner</TableCell>
          <TableCell>Frontend Team</TableCell>
          <TableCell align="end">$12,500</TableCell>
        </TableRow>
        <TableRow id="mobile-app" textValue="Mobile App">
          <TableCell>Mobile App</TableCell>
          <TableCell>Editor</TableCell>
          <TableCell>Mobile Team</TableCell>
          <TableCell align="end">$8,750</TableCell>
        </TableRow>
        <TableRow id="api-integration" textValue="API Integration">
          <TableCell>API Integration</TableCell>
          <TableCell>Editor</TableCell>
          <TableCell>Backend Team</TableCell>
          <TableCell align="end">$5,200</TableCell>
        </TableRow>
      </TableBody>
      <TableFooter>
        <TableRow id="total">
          <TableCell colSpan={3}>Total budget</TableCell>
          <TableCell align="end">$26,450</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  );
}
```

## Card composition

CardFrame supplies the surrounding surface; Table owns its rows and horizontal scrolling. Inside `CardFrameBleed`, the first and last columns take the frame's inset, so the table lines up with the frame header and footer. Card is included in the example dependencies under Installation.

Table in a CardFrame

[Complete TSX file](/examples/table/card-frame.tsx)

```tsx
"use client";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/siteplane/components/table";

import {
  CardFrame,
  CardFrameBleed,
  CardFrameHeader,
  CardFrameTitle,
} from "@/siteplane/components/card";

export default function Example() {
  return (
    <CardFrame className="w-full">
      <CardFrameHeader>
        <CardFrameTitle>Projects</CardFrameTitle>
      </CardFrameHeader>
      <CardFrameBleed>
        <Table aria-label="Projects">
          <TableHeader>
            <TableHead id="project" isRowHeader>
              Project
            </TableHead>
            <TableHead id="role">Role</TableHead>
            <TableHead id="team">Team</TableHead>
            <TableHead id="budget" align="end">
              Budget
            </TableHead>
          </TableHeader>
          <TableBody>
            <TableRow id="website-redesign" textValue="Website Redesign">
              <TableCell>Website Redesign</TableCell>
              <TableCell>Owner</TableCell>
              <TableCell>Frontend Team</TableCell>
              <TableCell align="end">$12,500</TableCell>
            </TableRow>
            <TableRow id="mobile-app" textValue="Mobile App">
              <TableCell>Mobile App</TableCell>
              <TableCell>Editor</TableCell>
              <TableCell>Mobile Team</TableCell>
              <TableCell align="end">$8,750</TableCell>
            </TableRow>
            <TableRow id="api-integration" textValue="API Integration">
              <TableCell>API Integration</TableCell>
              <TableCell>Editor</TableCell>
              <TableCell>Backend Team</TableCell>
              <TableCell align="end">$5,200</TableCell>
            </TableRow>
          </TableBody>
        </Table>
      </CardFrameBleed>
    </CardFrame>
  );
}
```

## API reference

### Table

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| hoverable | boolean | true | Enables header and row hover backgrounds. false disables them table-wide; selection, focus and actions remain available. |
| size | sm \| md \| lg \| xl | provider / lg | Shared table size preset. sm shortens the heading row and tightens the cell padding; lg and xl increase the row height. |
| selectionTrigger | row \| checkbox | row | Which interaction updates the selection. With checkbox, only TableSelectionCheckbox selects and a press on a row runs its action. |
| className | string | unset | Styles the table itself. A callback is not accepted. |
| containerClassName | string | unset | Styles the outer scrolling container. |
| selectionMode | none \| single \| multiple | none | Whether rows can be selected. |
| selectionBehavior | toggle \| replace | toggle | How row interaction updates selection and activates actions. Does not apply with selectionTrigger checkbox. |
| selectedKeys | Selection | uncontrolled | Controlled key set, or all. |
| defaultSelectedKeys | Iterable<Key> \| "all" | empty | Initial uncontrolled selection. |
| onSelectionChange | (keys: Selection) => void | unset | Receives updated selected keys. |
| disallowEmptySelection | boolean | false | Prevents clearing an existing selection. |
| disabledKeys | Iterable<Key> | empty | Rows whose interaction is restricted. |
| disabledBehavior | all \| selection | all | Disable all interaction or only selection for disabled rows. |
| sortDescriptor | SortDescriptor | unset | Active column and ascending/descending direction. |
| onSortChange | (descriptor: SortDescriptor) => void | unset | Requests sorting; application reorders data. |
| onRowAction | (key: Key) => void | unset | Root action callback. Use per-row onAction for Siteplane UI action styling. |
| aria-label / aria-labelledby | string | unset / caption ID | Accessible table name. Supply a name or a direct TableCaption. |
| children | ReactNode | unset | Header, body, optional direct caption and footer. |
### TableHeader and TableHead

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| TableHeader.children / columns | ReactNode or column render function / Iterable<T> | unset | Static columns or a dynamic column collection. |
| TableHeader.className | string | unset | Header styling. |
| TableHead.align | start \| center \| end | start | Heading alignment: start is left, center is centered, end is right. |
| TableHead.className | string | unset | Local column-heading styling. |
| TableHead.id | Key | unset | Stable column identity; used by sortDescriptor. |
| TableHead.isRowHeader | boolean | false | Identifies the column used as the accessible row heading. |
| TableHead.allowsSorting | boolean | false | Enables sorting interaction and the direction icon. |
| TableHead.children | ReactNode \| render function | unset | Heading content; render function receives column state. |
`TableHeader` creates the header row internally. `TableHead` retains React Aria
Column's collection and column sizing props; no resize handle or resize UI is
supplied by this wrapper.

### TableBody and TableFooter

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | ReactNode \| (item: T) => ReactElement | unset | Rows, or a render function for items. |
| items | Iterable<T> | unset | Dynamic row collection. |
| dependencies | ReadonlyArray<unknown> | unset | External values that invalidate a cached dynamic collection. |
| className | string | unset | Styles the body or footer. |
| TableBody.renderEmptyState | (state) => ReactNode | unset | Content when the body has no rows; not a footer prop. |
### TableRow

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| inactive | boolean | false | Dims the row only; does not disable it. |
| hoverable | boolean | true | Enables the ordinary hover fill. Action/focus states are separate. |
| className | string | unset | Local row styling. |
| id | Key | unset | Stable row key for selection and collection identity. |
| textValue | string | unset | Plain-text row label for typeahead and accessibility. |
| href | string | unset | Makes the row a link. Supports inherited target, rel and router options. |
| onAction | () => void | unset | Row action callback; enables interactive presentation. |
| isDisabled | boolean | false | Disables this row according to disabledBehavior. |
| disabledBehavior | all \| selection | inherited | Overrides the table disabled behavior for this row. |
| children / columns | ReactNode or cell render function / Iterable<T> | unset | Static or dynamic cells. |
### TableCell

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| align | start \| center \| end | start | Cell text alignment. |
| className | string | unset | Local cell styling. |
| colSpan | number | 1 | Number of columns occupied by this cell. |
| children | ReactNode \| render function | unset | Cell content, optionally based on React Aria cell state. |
### TableSelectionCheckbox

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | string | unset | Local checkbox styling. |
| isDisabled | boolean | table context | Optional explicit disabled state; normally inherited from selection context. |
| aria-label | string | table context | Accessible label; normally provided by the table. |
Place it inside a selection `TableHead` for select-all (multiple selection) or a
`TableCell` for a row. Siteplane UI fixes `slot="selection"` and supplies the
check/indeterminate indicator. `children` and `slot` are intentionally not
accepted. Other inherited Checkbox props are available, but do not detach its
selected state from the table.

### TableCaption and exported types

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | ReactNode | required | Content extracted by the direct parent Table; not a standalone native caption. |
| className | string | unset | Styles the caption below the scrolling table. |
Types exported by the Table module: `TableProps`, `TableCaptionProps`, `TableHeaderProps<T>`, `TableHeadProps`, `TableBodyProps<T>`, `TableFooterProps<T>`, `TableRowProps<T>`, `TableCellProps`, `TableSelectionCheckboxProps`, `TableAlign`, `TableSelectionTrigger`, and `SortDescriptor`. Import `Selection` and `Key` from `react-aria-components` when needed.

### Inherited contracts and boundaries

Siteplane UI's `className` props are strings, even where React Aria accepts callbacks. The `--table-edge-padding` custom property sets the inline padding of the first and last columns; `CardFrameBleed` sets it to the frame inset, and outside a frame the outer columns keep the regular cell padding. Other inherited collection/render props remain available. See the official [Table API](https://react-aria.adobe.com/Table#api) for Table, TableHeader, Column, TableBody, TableFooter, Row and Cell, and [Checkbox API](https://react-aria.adobe.com/Checkbox#api) for the underlying selection control. The [installed source](/source/table) defines the exact version and overrides used here.

Pagination, filtering, remote loading and budget calculations belong to your application. Advanced React Aria composition such as drag-and-drop or resizable columns needs its additional hooks/parts; Siteplane UI Table does not provide dedicated controls for those flows. Do not expect a sort arrow to sort data automatically, a status badge to disable a row, or `inactive` to prevent an action.

## Usage notes

Give the table an accessible name and at least one identifying row-header column. Arrow keys move through the grid, Home/End move within a row, and Ctrl+Home/Ctrl+End reach the beginning/end of the collection. Space selects a selectable row; with `selectionTrigger="checkbox"`, it selects only on the row's checkbox. Enter activates an actionable row. Selection behavior affects pointer activation as described above. Preserve the table's focus indicators and checkbox labels.

The table fills the width of its container, and cells don't wrap. When the columns need more room than the container provides, the table scrolls horizontally inside its own container and the page itself doesn't overflow; on mobile, this horizontal scrolling is expected. Do not squeeze all columns into unreadable widths. If you customize the table width or wrapping through `className`, check real content at narrow widths.
