Siteplane UI

Table

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

Markdown
ProjectStatusTeamBudget
Website RedesignPaidFrontend Team$12,500
Mobile AppUnpaidMobile Team$8,750
API IntegrationPendingBackend Team$5,200
Security AuditFailedSecurity Team$2,100
"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

pnpm dlx shadcn@4.18.0 add @siteplane/table

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

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

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.

ProjectRoleTeamBudget
Website RedesignOwnerFrontend Team$12,500
Mobile AppEditorMobile Team$8,750
API IntegrationEditorBackend Team$5,200
"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.

ProjectRoleTeamBudget
Website RedesignOwnerFrontend Team$12,500
Mobile AppEditorMobile Team$8,750
API IntegrationEditorBackend Team$5,200
"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 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).

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.

ProjectTeamActions
Website RedesignFrontend Team
Mobile AppMobile Team
API IntegrationBackend Team
"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.

ProjectTeam

No projects found.

"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.

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.

ProjectRoleTeamBudget
Website RedesignOwnerFrontend Team$12,500
Mobile AppEditorMobile Team$8,750
API IntegrationEditorBackend Team$5,200
Total budget$26,450
Current project budgets and team access.
"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.

Projects
ProjectRoleTeamBudget
Website RedesignOwnerFrontend Team$12,500
Mobile AppEditorMobile Team$8,750
API IntegrationEditorBackend Team$5,200
"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

Component properties
PropTypeDefaultDescription
hoverablebooleantrueEnables header and row hover backgrounds. false disables them table-wide; selection, focus and actions remain available.
sizesm | md | lg | xlprovider / lgShared table size preset. sm shortens the heading row and tightens the cell padding; lg and xl increase the row height.
selectionTriggerrow | checkboxrowWhich interaction updates the selection. With checkbox, only TableSelectionCheckbox selects and a press on a row runs its action.
classNamestringunsetStyles the table itself. A callback is not accepted.
containerClassNamestringunsetStyles the outer scrolling container.
selectionModenone | single | multiplenoneWhether rows can be selected.
selectionBehaviortoggle | replacetoggleHow row interaction updates selection and activates actions. Does not apply with selectionTrigger checkbox.
selectedKeysSelectionuncontrolledControlled key set, or all.
defaultSelectedKeysIterable<Key> | "all"emptyInitial uncontrolled selection.
onSelectionChange(keys: Selection) => voidunsetReceives updated selected keys.
disallowEmptySelectionbooleanfalsePrevents clearing an existing selection.
disabledKeysIterable<Key>emptyRows whose interaction is restricted.
disabledBehaviorall | selectionallDisable all interaction or only selection for disabled rows.
sortDescriptorSortDescriptorunsetActive column and ascending/descending direction.
onSortChange(descriptor: SortDescriptor) => voidunsetRequests sorting; application reorders data.
onRowAction(key: Key) => voidunsetRoot action callback. Use per-row onAction for Siteplane UI action styling.
aria-label / aria-labelledbystringunset / caption IDAccessible table name. Supply a name or a direct TableCaption.
childrenReactNodeunsetHeader, body, optional direct caption and footer.

TableHeader and TableHead

Component properties
PropTypeDefaultDescription
TableHeader.children / columnsReactNode or column render function / Iterable<T>unsetStatic columns or a dynamic column collection.
TableHeader.classNamestringunsetHeader styling.
TableHead.alignstart | center | endstartHeading alignment: start is left, center is centered, end is right.
TableHead.classNamestringunsetLocal column-heading styling.
TableHead.idKeyunsetStable column identity; used by sortDescriptor.
TableHead.isRowHeaderbooleanfalseIdentifies the column used as the accessible row heading.
TableHead.allowsSortingbooleanfalseEnables sorting interaction and the direction icon.
TableHead.childrenReactNode | render functionunsetHeading 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

Component properties
PropTypeDefaultDescription
childrenReactNode | (item: T) => ReactElementunsetRows, or a render function for items.
itemsIterable<T>unsetDynamic row collection.
dependenciesReadonlyArray<unknown>unsetExternal values that invalidate a cached dynamic collection.
classNamestringunsetStyles the body or footer.
TableBody.renderEmptyState(state) => ReactNodeunsetContent when the body has no rows; not a footer prop.

TableRow

Component properties
PropTypeDefaultDescription
inactivebooleanfalseDims the row only; does not disable it.
hoverablebooleantrueEnables the ordinary hover fill. Action/focus states are separate.
classNamestringunsetLocal row styling.
idKeyunsetStable row key for selection and collection identity.
textValuestringunsetPlain-text row label for typeahead and accessibility.
hrefstringunsetMakes the row a link. Supports inherited target, rel and router options.
onAction() => voidunsetRow action callback; enables interactive presentation.
isDisabledbooleanfalseDisables this row according to disabledBehavior.
disabledBehaviorall | selectioninheritedOverrides the table disabled behavior for this row.
children / columnsReactNode or cell render function / Iterable<T>unsetStatic or dynamic cells.

TableCell

Component properties
PropTypeDefaultDescription
alignstart | center | endstartCell text alignment.
classNamestringunsetLocal cell styling.
colSpannumber1Number of columns occupied by this cell.
childrenReactNode | render functionunsetCell content, optionally based on React Aria cell state.

TableSelectionCheckbox

Component properties
PropTypeDefaultDescription
classNamestringunsetLocal checkbox styling.
isDisabledbooleantable contextOptional explicit disabled state; normally inherited from selection context.
aria-labelstringtable contextAccessible 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

Component properties
PropTypeDefaultDescription
childrenReactNoderequiredContent extracted by the direct parent Table; not a standalone native caption.
classNamestringunsetStyles 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 for Table, TableHeader, Column, TableBody, TableFooter, Row and Cell, and Checkbox API for the underlying selection control. The installed source 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.