Skip to content

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.

On this page

Example

namerolecity
Ada LovelaceEngineerLondon
Grace HopperAdmiralNew York
Mary JacksonEngineerHampton
Table.tsxtsx
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.

  1. Table

    Native table with the grid role and one tab stop. Owns a DOM element.

  2. TableHeader

    Native thead holding the column header row. Owns a DOM element.

  3. TableColumn

    Native th column header and grid cell. Owns a DOM element.

  4. ResizerOptional

    Optional drag handle inside a resizable column. Owns a DOM element.

  5. TableBody

    Native tbody holding the data rows. Owns a DOM element.

  6. TableRow

    Native tr in either section. Owns a DOM element.

  7. TableCell

    Native td data cell and grid focus target. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Start Table with TableHeader and one TableColumn per column.

  2. 2

    Add the supporting parts

    Add a TableRow of TableCell parts to TableBody for each record.

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

Importtsx
import { Checkbox, Table, TableBody, TableCell, TableColumn, Resizer, TableHeader, TableRow } from "@comp0/react";

Table

DOM element

Native table with the grid role and one tab stop.

PropTypeDescription
aria-labelstringNames the table when there is no visible caption.
onRangeSelect(values: string[]) => voidReceives anchor-to-row values on Shift+Click and Shift+ArrowUp/Down.

TableHeader

DOM element

Native thead holding the column header row.

TableColumn

DOM element

Native th column header and grid cell.

PropTypeDescription
sort"ascending" | "descending" | "none"Current sort, exposed as aria-sort; you sort the rows.
onSort() => voidRuns on click, Enter, or Space.
onResize(width: number) => voidReceives the next width from Shift+Arrow or a resizer drag.

Resizer

OptionalDOM element

Optional drag handle inside a resizable column.

TableBody

DOM element

Native tbody holding the data rows.

TableRow

DOM element

Native tr in either section.

PropTypeDescription
selectedbooleanMarks the row selected for aria and styling; you own the state.
valuestringRow identity reported by onRangeSelect.

TableCell

DOM element

Native 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-visible

on TableColumn, TableCell

The cell has keyboard focus.

Keep exploring