Skip to main contentSkip to docs navigation

Chassis Table is a full interactive data grid with sorting, row selection, and arrow-key grid navigation, built on React Aria's table collection hooks.

Overview

Table is a real ARIA grid (role="grid") — arrow keys move focus between cells, columns can be sortable, and rows can be selectable, all handled by React Aria's table hooks. Composing it uses TableHeader/TableColumn/TableBody/TableRow/TableCell as data — read to build the table's collection — rather than plain <thead>/<tbody> JSX. Table renders the real <table> markup itself from that collection.

This is a deliberate trade: the authoring model is less "just markup" than most of the rest of Chassis React, in exchange for real sorting, selection, and keyboard grid navigation that would otherwise need to be hand-rolled.

If a table needs none of that, and especially if it's rendered from a React Server Component, use StaticTable instead. It takes the same styling props, renders plain markup, and ships no JavaScript.

First nameLast nameUsername
MarkOtto@mdo
JacobThornton@fat
LarryBird@twitter
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

export const Example = () => (
  <Table aria-label="Users">
    <TableHeader>
      <TableColumn key="firstName">First name</TableColumn>
      <TableColumn key="lastName">Last name</TableColumn>
      <TableColumn key="handle">Username</TableColumn>
    </TableHeader>
    <TableBody>
      <TableRow>
        <TableCell>Mark</TableCell>
        <TableCell>Otto</TableCell>
        <TableCell>@mdo</TableCell>
      </TableRow>
      <TableRow>
        <TableCell>Jacob</TableCell>
        <TableCell>Thornton</TableCell>
        <TableCell>@fat</TableCell>
      </TableRow>
      <TableRow>
        <TableCell>Larry</TableCell>
        <TableCell>Bird</TableCell>
        <TableCell>@twitter</TableCell>
      </TableRow>
    </TableBody>
  </Table>
)

Dynamic columns and rows

Both TableHeader and TableBody also accept a columns/items array plus a render function, for columns and rows built from data rather than written out by hand.

First nameLast nameUsername
MarkOtto@mdo
JacobThornton@fat
LarryBird@twitter
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const columns = [
  { id: 'firstName', name: 'First name' },
  { id: 'lastName', name: 'Last name' },
  { id: 'handle', name: 'Username' }
]

const rows = [
  { id: 1, firstName: 'Mark', lastName: 'Otto', handle: '@mdo' },
  { id: 2, firstName: 'Jacob', lastName: 'Thornton', handle: '@fat' },
  { id: 3, firstName: 'Larry', lastName: 'Bird', handle: '@twitter' }
]

export const Example = () => (
  <Table aria-label="Users">
    <TableHeader columns={columns}>
      {(column) => <TableColumn key={column.id}>{column.name}</TableColumn>}
    </TableHeader>
    <TableBody items={rows}>
      {(row) => (
        <TableRow key={row.id}>
          {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
        </TableRow>
      )}
    </TableBody>
  </Table>
)

Sorting

Add allowsSorting to a TableColumn to make it clickable (and keyboard-activatable). Table doesn't sort the data itself — it reports the requested column/direction via onSortChange, so pair it with sortDescriptor and sort the array in that handler, as shown below.

NameRole
Ashley GrantProduct
Jacob ThorntonDesigner
Larry BirdEngineer
Mark OttoEngineer
JSX
import { useMemo, useState } from 'react'
import type { SortDescriptor } from 'react-stately'
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const rows = [
  { id: 1, name: 'Mark Otto', role: 'Engineer' },
  { id: 2, name: 'Jacob Thornton', role: 'Designer' },
  { id: 3, name: 'Larry Bird', role: 'Engineer' },
  { id: 4, name: 'Ashley Grant', role: 'Product' }
]

export const Example = () => {
  const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
    column: 'name',
    direction: 'ascending'
  })

  const sortedRows = useMemo(() => {
    const key = sortDescriptor.column as keyof (typeof rows)[number]
    const sorted = [...rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0))
    return sortDescriptor.direction === 'descending' ? sorted.reverse() : sorted
  }, [sortDescriptor])

  return (
    <Table aria-label="Team" onSortChange={setSortDescriptor} sortDescriptor={sortDescriptor}>
      <TableHeader>
        <TableColumn key="name" allowsSorting>
          Name
        </TableColumn>
        <TableColumn key="role" allowsSorting>
          Role
        </TableColumn>
      </TableHeader>
      <TableBody items={sortedRows}>
        {(row) => (
          <TableRow key={row.id}>
            {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
          </TableRow>
        )}
      </TableBody>
    </Table>
  )
}

Selection

Set selectionMode="multiple" for checkbox-based row selection (including a select-all checkbox in the header), or selectionMode="single" to select a row by clicking anywhere in it. Control the selection with selectedKeys/onSelectionChange — a selected row gets the same .active highlight the table's active variant applies.

NameRole
Mark OttoEngineer
Jacob ThorntonDesigner
Larry BirdEngineer
JSX
import { useState } from 'react'
import type { Selection } from 'react-stately'
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const rows = [
  { id: 1, name: 'Mark Otto', role: 'Engineer' },
  { id: 2, name: 'Jacob Thornton', role: 'Designer' },
  { id: 3, name: 'Larry Bird', role: 'Engineer' }
]

export const Example = () => {
  const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())

  return (
    <Table
      aria-label="Team"
      onSelectionChange={setSelectedKeys}
      selectedKeys={selectedKeys}
      selectionMode="multiple"
    >
      <TableHeader>
        <TableColumn key="name">Name</TableColumn>
        <TableColumn key="role">Role</TableColumn>
      </TableHeader>
      <TableBody items={rows}>
        {(row) => (
          <TableRow key={row.id}>
            {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
          </TableRow>
        )}
      </TableBody>
    </Table>
  )
}

Keyboard navigation

Once a cell has focus, arrow keys move focus across the grid — no extra setup required. Typeahead, Home/End, and Page Up/Down navigation come from the same hooks.

Variants

bordered, borderless, hover, sm, striped, and color all apply to the <table> element itself, the same as any other table markup.

NameRole
Mark OttoEngineer
Jacob ThorntonDesigner
Larry BirdEngineer
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const rows = [
  { id: 1, name: 'Mark Otto', role: 'Engineer' },
  { id: 2, name: 'Jacob Thornton', role: 'Designer' },
  { id: 3, name: 'Larry Bird', role: 'Engineer' }
]

export const Example = () => (
  <Table aria-label="Team" bordered hover sm striped>
    <TableHeader>
      <TableColumn key="name">Name</TableColumn>
      <TableColumn key="role">Role</TableColumn>
    </TableHeader>
    <TableBody items={rows}>
      {(row) => (
        <TableRow key={row.id}>
          {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
        </TableRow>
      )}
    </TableBody>
  </Table>
)

Pass caption for a <caption> above the table. Pass footer for a <tfoot> below it — footer content is rendered as plain markup, not part of the keyboard-navigable grid, since totals/summary rows don't need to participate in cell-by-cell navigation.

Recent invoices
NameAmount
Mark Otto$120
Jacob Thornton$80
Total$200
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const rows = [
  { id: 1, name: 'Mark Otto', amount: '$120' },
  { id: 2, name: 'Jacob Thornton', amount: '$80' }
]

export const Example = () => (
  <Table
    aria-label="Invoices"
    caption="Recent invoices"
    footer={
      <tr>
        <td>Total</td>
        <td>$200</td>
      </tr>
    }
  >
    <TableHeader>
      <TableColumn key="name">Name</TableColumn>
      <TableColumn key="amount">Amount</TableColumn>
    </TableHeader>
    <TableBody items={rows}>
      {(row) => (
        <TableRow key={row.id}>
          {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
        </TableRow>
      )}
    </TableBody>
  </Table>
)

Responsive tables

Wrap in a responsive container with the responsive prop — true for all viewports, or a breakpoint name to scroll only below it.

Column 1Column 2Column 3Column 4Column 5Column 6Column 7
Row 1, Column 1Row 1, Column 2Row 1, Column 3Row 1, Column 4Row 1, Column 5Row 1, Column 6Row 1, Column 7
Row 2, Column 1Row 2, Column 2Row 2, Column 3Row 2, Column 4Row 2, Column 5Row 2, Column 6Row 2, Column 7
Row 3, Column 1Row 3, Column 2Row 3, Column 3Row 3, Column 4Row 3, Column 5Row 3, Column 6Row 3, Column 7
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const columns = ['Column 1', 'Column 2', 'Column 3', 'Column 4', 'Column 5', 'Column 6', 'Column 7']
const rows = [1, 2, 3]

export const Example = () => (
  <Table aria-label="Wide data" responsive>
    <TableHeader>
      {columns.map((col) => (
        <TableColumn key={col}>{col}</TableColumn>
      ))}
    </TableHeader>
    <TableBody items={rows.map((id) => ({ id }))}>
      {(row) => (
        <TableRow key={row.id}>
          {(columnKey) => <TableCell>{`Row ${row.id}, ${columnKey}`}</TableCell>}
        </TableRow>
      )}
    </TableBody>
  </Table>
)

Stacked tables

For tables with too many columns to read comfortably even with horizontal scrolling, set stacked to convert rows into stacked label/value blocks below a container width — true always stacks, or pass a breakpoint name to stack only below it. Resize the panel below to see it switch.

Each TableColumn's text becomes the label shown before that column's value; set textValue on a column explicitly when its header isn't plain text (an icon, a Tooltip, etc.).

NameRoleEmailStatus
Alice MartinAdminalice@example.comActive
Bob ChenEditorbob@example.comActive
Carol WhiteViewercarol@example.comInactive
JSX
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from '@chassis-ui/react'

const rows = [
  { id: 1, name: 'Alice Martin', role: 'Admin', email: 'alice@example.com', status: 'Active' },
  { id: 2, name: 'Bob Chen', role: 'Editor', email: 'bob@example.com', status: 'Active' },
  { id: 3, name: 'Carol White', role: 'Viewer', email: 'carol@example.com', status: 'Inactive' }
]

export const Example = () => (
  <Table aria-label="Users" stacked="md">
    <TableHeader>
      <TableColumn key="name">Name</TableColumn>
      <TableColumn key="role">Role</TableColumn>
      <TableColumn key="email">Email</TableColumn>
      <TableColumn key="status">Status</TableColumn>
    </TableHeader>
    <TableBody items={rows}>
      {(row) => (
        <TableRow key={row.id}>
          {(columnKey) => <TableCell>{row[columnKey as keyof typeof row]}</TableCell>}
        </TableRow>
      )}
    </TableBody>
  </Table>
)

Scope

  • No virtualization. Every row renders directly — there's no windowing for very lg datasets. React Aria doesn't ship a public general-purpose virtualizer for Table (its internal one isn't meant for outside use), and layering in a separate library (e.g. @tanstack/react-virtual) is a substantial enough change — fixed/measured row heights, a scroll container, and interaction with React Aria's own keyboard navigation and focus management — that it's deferred rather than bundled into this first pass. Most lg datasets are better served by server-side pagination anyway.
  • No column resizing or reordering, no nested/expandable rows. React Aria's table hooks support these, but they're not wired up here.

API

Table

PropTypeDefault
align?"bottom" | "middle" | "top"—
Set the vertical alignment of cell content.
aria-label?string—
An accessible label for the table, used when there's no visible heading.
aria-labelledby?string—
Identifies a visible heading element for the table.
bordered?boolean—
Add borders on all sides of the table and cells.
borderless?boolean—
Remove borders on all sides of the table and cells.
caption?ReactNode—
Content shown above the table, functioning as a heading for it.
children[ReactElement<TableHeaderProps<T> & { className?: string | undefined; }, string | JSXElementConstructor<any>>, ReactElement<...>]—
A TableHeader and a TableBody, each built from TableColumn/TableRow/ TableCell — read as data to build the table's collection. Not rendered directly.
className?string—
A string of all className you want applied to the component.
color?ContextColor—
Sets the color of the component.
disabledKeys?Iterable<Key>—
A list of row keys to disable. Disabled rows cannot be selected, focused, or interacted with.
footer?ReactNode—
Content shown below the table in a <tfoot>, e.g. a totals row. Rendered as plain markup — not part of the keyboard-navigable grid.
hover?boolean—
Enable a hover state on table rows.
id?string—
id forwarded to the table element.
onSelectionChange?((keys: Selection) => void)—
Handler that is called when the selection changes.
onSortChange?((descriptor: SortDescriptor) => void)—
Handler that is called when a column is sorted.
ref?Ref<HTMLTableElement>—
responsive?boolean | "sm" | "md" | "lg" | "xl" | "2xl"—
Make any table responsive across all viewports or pick a maximum breakpoint.
selectedKeys?Selection—
The currently selected row keys (controlled).
selectionMode?"none" | "single" | "multiple"none
The type of selection that is allowed.
sm?boolean—
Make table more compact by cutting all cell padding.
sortDescriptor?SortDescriptor—
The current sort column and direction.
stacked?boolean | "sm" | "md" | "lg" | "xl" | "2xl"—
Convert rows into stacked label/value blocks below a container width, for tables with too many columns to read comfortably even with horizontal scrolling. true always stacks; a breakpoint name stacks only below it. Implies responsive when responsive isn't set separately, since stacking needs the same .table-responsive container-query ancestor. Labels come from each TableColumn's text (or its textValue, for non-text headers).
striped?boolean—
Add zebra-striping to table rows.

TableHeader

PropTypeDefault
childrenReactElement<unknown, string | JSXElementConstructor<any>> | ReactElement<unknown, string | JSXElementConstructor<any>>[] | ((column: T) => ReactElement<...>)—
TableColumn elements, or a render function paired with columns for dynamic column generation.
className?string—
A string of all className you want applied to the <thead> element.
columns?readonly T[]—
A list of column data objects, rendered via the function form of children.

TableColumn

PropTypeDefault
allowsSorting?boolean—
Whether the column allows sorting. Adds a sort indicator and makes the header clickable/keyboard-activatable.
childrenReactNode—
Rendered contents of the column header.
className?string—
A string of all className you want applied to the column header.
textValue?string—
A string representation of the column header, used for accessibility announcements and, when Table's stacked prop is set, as the label shown before each row's value for this column. Defaults to children when it's a plain string — set this explicitly when the header contains anything else (an icon, a Tooltip, etc.).

TableBody

PropTypeDefault
childrenReactElement<unknown, string | JSXElementConstructor<any>> | ReactElement<unknown, string | JSXElementConstructor<any>>[] | ((item: T) => ReactElement<...>)—
TableRow elements, or a render function paired with items for dynamic row generation.
className?string—
A string of all className you want applied to the <tbody> element.
items?Iterable<T>—
A list of row data objects, rendered via the function form of children.

TableRow

PropTypeDefault
childrenReactElement<unknown, string | JSXElementConstructor<any>> | ReactElement<unknown, string | JSXElementConstructor<any>>[] | ((columnKey: Key) => ReactElement<...>)—
TableCell elements, or a render function called once per column with that column's key — required when the row's parent TableBody uses the items/render-function form.
className?string—
A string of all className you want applied to the row.
textValue?string—
A string representation of the row's contents, used for typeahead.

TableCell

PropTypeDefault
childrenReactNode—
The contents of the cell.
className?string—
A string of all className you want applied to the cell.
colSpan?number—
Indicates how many columns the cell spans.
textValue?string—
A string representation of the cell's contents, used for typeahead.