Navigation
Table
A native table you can walk cell by cell from the keyboard.
When to use it: Use it for records where rows and columns both carry meaning.
Example
| name ↑ | role | city | |
|---|---|---|---|
| Ada Lovelace | Engineer | London | |
| Grace Hopper | Admiral | New York | |
| Mary Jackson | Engineer | Hampton |
import { useState } from "react";
import {
Checkbox,
Resizer,
Select,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@comp0/react";
export function Example() {
const people = [
{ id: "ada", name: "Ada Lovelace", role: "Engineer", city: "London" },
{ id: "grace", name: "Grace Hopper", role: "Admiral", city: "New York" },
{ id: "mary", name: "Mary Jackson", role: "Engineer", city: "Hampton" },
];
const columns = ["name", "role", "city"] as const;
const [sort, setSort] = useState<"ascending" | "descending">("ascending");
const [selected, setSelected] = useState<string[]>(["ada"]);
const [widths, setWidths] = useState<Record<(typeof columns)[number], number>>({
name: 150,
role: 110,
city: 110,
});
const rows = [...people].sort((a, b) =>
sort === "ascending" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name),
);
const toggle = (id: string, on: boolean) =>
setSelected((current) => (on ? [...current, id] : current.filter((entry) => entry !== id)));
const toggleSort = () =>
setSort((current) => (current === "ascending" ? "descending" : "ascending"));
return (
<Table
aria-label="People"
aria-multiselectable="true"
className="w-full max-w-md table-fixed border-collapse text-base sm:text-sm"
onRangeSelect={setSelected}
>
<TableHeader>
<TableRow>
<TableColumn
aria-label="Selection"
className="w-10 border-b border-zinc-950/10 px-2 py-1.5 outline-teal-600 focus-visible:outline-2 dark:border-white/10 dark:outline-teal-400"
/>
{columns.map((column) => (
<TableColumn
key={column}
className="relative border-b border-zinc-950/10 px-2 py-1.5 text-left font-semibold text-zinc-950 capitalize outline-teal-600 focus-visible:outline-2 dark:border-white/10 dark:text-zinc-50 dark:outline-teal-400"
style={{ width: widths[column] }}
sort={column === "name" ? sort : undefined}
onSort={column === "name" ? toggleSort : undefined}
onResize={(width) => setWidths((current) => ({ ...current, [column]: width }))}
>
{column}
{column === "name" && (sort === "ascending" ? " \u2191" : " \u2193")}
<Resizer
aria-label={`Resize ${column} column`}
className="absolute inset-y-0 right-0 w-1 bg-zinc-950/10 outline-teal-600 focus-visible:outline-2 data-dragging:bg-teal-600 dark:bg-white/10 dark:outline-teal-400 dark:data-dragging:bg-teal-400"
min={60}
max={280}
size={widths[column]}
/>
</TableColumn>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((person) => (
<TableRow
key={person.id}
value={person.id}
selected={selected.includes(person.id)}
className="data-selected:bg-teal-50 dark:data-selected:bg-teal-950/40"
>
<TableCell className="border-b border-zinc-950/5 px-2 py-1.5 outline-teal-600 focus-visible:outline-2 dark:border-white/5 dark:outline-teal-400">
<Checkbox
aria-label={`Select ${person.name}`}
className="group"
checked={selected.includes(person.id)}
onChange={(on) => toggle(person.id, on)}
>
<span className="block size-4 rounded-sm border border-zinc-950/20 bg-white ring-2 ring-transparent group-data-focused:ring-teal-600 group-data-selected:border-teal-600 group-data-selected:bg-teal-600 dark:border-white/20 dark:bg-zinc-900 dark:group-data-focused:ring-teal-400" />
</Checkbox>
</TableCell>
{[person.name, person.role, person.city].map((cell) => (
<TableCell
key={cell}
className="truncate border-b border-zinc-950/5 px-2 py-1.5 text-zinc-700 outline-teal-600 focus-visible:outline-2 dark:border-white/5 dark:text-zinc-200 dark:outline-teal-400"
>
{cell}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}Anatomy
Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.
A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.
Table
Native table with the grid role and one tab stop. Owns a DOM element.
TableHeader
Native thead holding the column header row. Owns a DOM element.
TableColumn
Native th column header and grid cell. Owns a DOM element.
ResizerOptional
Optional drag handle inside a resizable column. Owns a DOM element.
TableBody
Native tbody holding the data rows. Owns a DOM element.
TableRow
Native tr in either section. Owns a DOM element.
TableCell
Native td data cell and grid focus target. Owns a DOM element.
Step by step
- 1
Add the main part
Start Table with TableHeader and one TableColumn per column.
- 2
Add the supporting parts
Add a TableRow of TableCell parts to TableBody for each record.
- 3
Make the behavior clear
Arrow keys move one cell; Home and End travel the row; Ctrl jumps to the corners.
Exampletsx <Table aria-label="People"><TableHeader><TableRow><TableColumn>Name</TableColumn></TableRow></TableHeader><TableBody><TableRow><TableCell>Ada</TableCell></TableRow></TableBody></Table>
Keyboard
- →
- Moves right through each cell and the controls inside it.
- ←
- Moves left through each cell and the controls inside it.
- ↓
- Moves one cell down the column.
- ↑
- Moves one cell up the column.
- Home
- Moves to the first cell in the row.
- End
- Moves to the last cell in the row.
- CtrlHome
- Moves to the first cell of the table.
- CtrlEnd
- Moves to the last cell of the table.
Forms and accessibility
No native form behavior; it presents data.
Accessibility checklist
- Give the table an aria-label or a visible caption.
- Keep column headers in TableColumn so cells inherit their names.
- Keep the focused cell visible while arrowing through the grid.
API reference
import { Checkbox, Table, TableBody, TableCell, TableColumn, Resizer, TableHeader, TableRow } from "@comp0/react";Table
DOM elementNative table with the grid role and one tab stop.
| Prop | Type | Description |
|---|---|---|
aria-label | string | Names the table when there is no visible caption. |
onRangeSelect | (values: string[]) => void | Receives anchor-to-row values on Shift+Click and Shift+ArrowUp/Down. |
TableHeader
DOM elementNative thead holding the column header row.
TableColumn
DOM elementNative th column header and grid cell.
| Prop | Type | Description |
|---|---|---|
sort | "ascending" | "descending" | "none" | Current sort, exposed as aria-sort; you sort the rows. |
onSort | () => void | Runs on click, Enter, or Space. |
onResize | (width: number) => void | Receives the next width from Shift+Arrow or a resizer drag. |
Resizer
OptionalDOM elementOptional drag handle inside a resizable column.
TableBody
DOM elementNative tbody holding the data rows.
TableRow
DOM elementNative tr in either section.
| Prop | Type | Description |
|---|---|---|
selected | boolean | Marks the row selected for aria and styling; you own the state. |
value | string | Row identity reported by onRangeSelect. |
TableCell
DOM elementNative td data cell and grid focus target.
Style hooks
Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.
:focus-visibleon TableColumn, TableCell
- The cell has keyboard focus.