Primitives
Table
Data-readout table with mono cells and uppercase heads.
● LIVE
| Sec ID | Grid Ref | Status |
|---|---|---|
| SEC-7F2A | 12.044, -7.318 | ONLINE |
| SEC-3C19 | 08.771, -3.902 | ARMED |
| SEC-9B40 | 15.230, -1.677 | IDLE |
| SEC-D5E8 | 04.918, -9.245 | ERROR |
"use client";import { Badge } from "@/registry/okibi/ui/badge";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/registry/okibi/ui/table";const rows = [ { id: "SEC-7F2A", grid: "12.044, -7.318", status: "ONLINE" as const }, { id: "SEC-3C19", grid: "08.771, -3.902", status: "ARMED" as const }, { id: "SEC-9B40", grid: "15.230, -1.677", status: "IDLE" as const }, { id: "SEC-D5E8", grid: "04.918, -9.245", status: "ERROR" as const },];const statusVariant = { ONLINE: "success", ARMED: "cyan", IDLE: "info", ERROR: "destructive",} as const;export default function TableDemo() { return ( <Table> <TableHeader> <TableRow> <TableHead>Sec ID</TableHead> <TableHead>Grid Ref</TableHead> <TableHead>Status</TableHead> </TableRow> </TableHeader> <TableBody> {rows.map((row) => ( <TableRow key={row.id}> <TableCell>{row.id}</TableCell> <TableCell>{row.grid}</TableCell> <TableCell> <Badge variant={statusVariant[row.status]}>{row.status}</Badge> </TableCell> </TableRow> ))} </TableBody> </Table> );}npx shadcn@latest add https://okibi.cndr.dev/r/table.jsonInstallation
Install the theme first, then add the component:
npx shadcn@latest add https://okibi.cndr.dev/r/table.jsonCopy the source from the Code tab above into components/ui/table.tsx. It uses the cn helper and the okibi theme tokens — install the theme first if you haven't.
Usage
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
<Table>
<TableHeader>
<TableRow>
<TableHead>Channel</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>CH-01</TableCell>
<TableCell>Online</TableCell>
</TableRow>
</TableBody>
</Table>Anatomy
<Table>
<TableCaption>Channel roster</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Channel</TableHead>
<TableHead className="text-right">Throughput</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>CH-01</TableCell>
<TableCell className="text-right tabular-nums">1280</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell className="text-right tabular-nums">1280</TableCell>
</TableRow>
</TableFooter>
</Table>Examples
Selection & totals
A data table with a selected row, a right-aligned numeric column, a TableFooter total, and a TableCaption.
● LIVE
| Bus ID | Node | Draw (kW) |
|---|---|---|
| PWR-01A | Reactor Core | 4,820 |
| PWR-02B | Shield Array | 1,375 |
| PWR-03C | Sensor Grid | 642 |
| PWR-04D | Life Support | 938 |
| Total | 7,775 | |
"use client";import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow,} from "@/registry/okibi/ui/table";const rows = [ { id: "PWR-01A", node: "Reactor Core", draw: 4820, selected: true }, { id: "PWR-02B", node: "Shield Array", draw: 1375, selected: false }, { id: "PWR-03C", node: "Sensor Grid", draw: 642, selected: false }, { id: "PWR-04D", node: "Life Support", draw: 938, selected: false },];const total = rows.reduce((sum, row) => sum + row.draw, 0);export default function TableSelectionDemo() { return ( <Table> <TableCaption>Power distribution — kW draw per node.</TableCaption> <TableHeader> <TableRow> <TableHead>Bus ID</TableHead> <TableHead>Node</TableHead> <TableHead className="text-right">Draw (kW)</TableHead> </TableRow> </TableHeader> <TableBody> {rows.map((row) => ( <TableRow key={row.id} data-state={row.selected ? "selected" : undefined}> <TableCell>{row.id}</TableCell> <TableCell>{row.node}</TableCell> <TableCell className="text-right tabular-nums"> {row.draw.toLocaleString()} </TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell colSpan={2}>Total</TableCell> <TableCell className="text-right tabular-nums"> {total.toLocaleString()} </TableCell> </TableRow> </TableFooter> </Table> );}Empty state
Render a single full-width muted row for the empty state.
● LIVE
| Sec ID | Grid Ref | Status |
|---|---|---|
| No records | ||
"use client";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/registry/okibi/ui/table";export default function TableEmptyDemo() { return ( <Table> <TableHeader> <TableRow> <TableHead>Sec ID</TableHead> <TableHead>Grid Ref</TableHead> <TableHead>Status</TableHead> </TableRow> </TableHeader> <TableBody> <TableRow> <TableCell colSpan={3} className="py-8 text-center text-muted-foreground" > No records </TableCell> </TableRow> </TableBody> </Table> );}Accessibility
- Renders native table semantics (
table/thead/tbody/tfoot/tr/th/td), so row and column relationships are exposed to assistive tech for free. - Use
TableCaptionto title the table — it renders a nativecaptionthat names the grid for screen readers. - The scrollable
overflow-x-autocontainer is not keyboard-reachable by default; for wide tables addrole='region',tabIndex={0}, and anaria-labelso keyboard-only users can scroll it. - Selected rows are marked with
data-[state=selected]; pair that with an in-row control (e.g. a checkbox) so the selection is operable, not just styled.
Guidelines
Do
- Right-align numeric columns and add
tabular-nums(className='text-right tabular-nums') so figures line up in the mono body. - Give every table a
TableCaptionor an equivalent heading. - Use
TableFooterfor totals — it carries the heavierborder-border-strongtop rule that reads as a summary row. - For long text columns, opt out of the default
whitespace-nowrapwithwhitespace-normalso content wraps instead of forcing horizontal scroll.
Don't
- Don't use the table for layout — it's a data-readout grid and its semantics are meaningful to AT.
- Don't leave a wide, scrolling table without a focusable, labeled scroll region.
- Don't hand-roll header styling —
TableHeadalready supplies the uppercasefont-uiheads over the strong rule.