Table
A semantic table for small, static data sets inside cards and settings.
Top suppliers by AP spend
September 2026| Supplier | Invoices | AP spend | Days to pay |
|---|---|---|---|
| Northwind Freight | 412 | $1,284,300.00 | 28 |
| Halcyon Health | 296 | $918,450.00 | 31 |
| Orchard Street Market | 138 | $402,120.00 | 24 |
| Meridian Parts Co. | 121 | $356,980.00 | 35 |
| Cobalt Ridge Foods | 97 | $241,760.00 | 29 |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";export function Hero() { const suppliers = [ { name: "Northwind Freight", invoices: 412, spend: "$1,284,300.00", days: 28, }, { name: "Halcyon Health", invoices: 296, spend: "$918,450.00", days: 31, }, { name: "Orchard Street Market", invoices: 138, spend: "$402,120.00", days: 24, }, { name: "Meridian Parts Co.", invoices: 121, spend: "$356,980.00", days: 35, }, { name: "Cobalt Ridge Foods", invoices: 97, spend: "$241,760.00", days: 29, }, ]; return ( <section aria-labelledby="top-suppliers-title" className="w-full max-w-2xl rounded-xl bg-card p-4 shadow-border" > <div className="mb-2 flex items-baseline justify-between gap-4"> <h3 id="top-suppliers-title" className="text-sm font-semibold"> Top suppliers by AP spend </h3> <span className="text-xs text-muted-foreground"> September 2026 </span> </div> <Table aria-labelledby="top-suppliers-title" className="text-[13px]" > <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Supplier </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Invoices </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> AP spend </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground max-sm:hidden"> Days to pay </TableHead> </TableRow> </TableHeader> <TableBody> {suppliers.map((supplier) => ( <TableRow key={supplier.name}> <TableCell className="font-medium"> {supplier.name} </TableCell> <TableCell className="text-right tabular-nums"> {supplier.invoices} </TableCell> <TableCell className="text-right tabular-nums"> {supplier.spend} </TableCell> <TableCell className="text-right tabular-nums max-sm:hidden"> {supplier.days} </TableCell> </TableRow> ))} </TableBody> </Table> </section> );}Usage#
Table is a thin, semantic wrapper over <table> for small, static data sets: a leaderboard in a report panel, the deals behind a pipeline chart, a cohort breakdown, a list of reports. It gives you a horizontally scrolling container, hairline row rules and a hover fill, and nothing else: no selection, pinning, resize or sticky header. The thing people get wrong is shipping the defaults. Out of the box the header is 40px Graphite Ink at 14px; in Canon a table inside a card reads at 13px with 32px Slate Meta headers, so almost every product table sets those itself.
When to use
- For a short table inside a report card, panel or sheet: a leaderboard, the top suppliers by AP spend, the deals behind a chart.
- For read-only rows where the columns are worth comparing, with numbers right-aligned.
- For an index of a few dozen items with one link per row and a row menu, such as reports or skills.
- When you need a footer row for totals or a caption that explains the data.
When not to use
- For a record list page with selection, pinned columns, resize or bulk actions. Use Data grid
- For a table that is the whole content of a settings section, such as API keys or members. Use Card table
- For two-column label and value pairs about one record. Use Meta line
- For rows with a title, description and trailing action but no columns to compare. Use Item
- For a trend over time. Show the shape as a chart and put the table under it only if people need the exact figures. Use Chart
The Tabular Figures Rule
text-right tabular-nums to the head and every cell in the column.The Thirteen-Fourteen Rule
text-[13px] to Table and set the head size yourself.The Tint Well Rule
Anatomy#
| Payment run | Invoices | Amount |
|---|---|---|
| Friday, Oct 2 | 212 | $684,210.00 |
| Friday, Sep 25 | 198 | $602,775.00 |
| Total | 410 | $1,286,985.00 |
- Container. A
relative w-full overflow-x-autodiv around the<table>, so wide tables scroll sideways inside their card. - Header row.
TableHeaderandTableHead. 40px by default; in cards set it to 32px, 12px, regular weight, Slate Meta. - Row.
TableRowwith a hairline bottom rule. The last row in the body drops its rule. - Numeric cell. A
TableCellwithtext-right tabular-nums, matched by the same classes on its head. - Footer.
TableFooter, a Well Gray band at 50% with a top hairline and medium weight, for totals.
Examples#
Dense, inside a card
The recipe product tables use: text-[13px] on the table, 32px 12px Slate Meta headers, and numeric columns right-aligned in tabular figures, head included.
| Run date | Invoices | Suppliers | Amount |
|---|---|---|---|
| Friday, Oct 2 | 212 | 48 | $684,210.00 |
| Friday, Sep 25 | 198 | 45 | $602,775.00 |
| Friday, Sep 18 | 176 | 41 | $541,090.00 |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";export function Dense() { const runs = [ { date: "Friday, Oct 2", invoices: 212, suppliers: 48, amount: "$684,210.00", }, { date: "Friday, Sep 25", invoices: 198, suppliers: 45, amount: "$602,775.00", }, { date: "Friday, Sep 18", invoices: 176, suppliers: 41, amount: "$541,090.00", }, ]; return ( <div className="w-full max-w-xl rounded-xl bg-card px-4 py-2 shadow-border"> <Table aria-label="Recent payment runs" className="text-[13px]"> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Run date </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Invoices </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Suppliers </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Amount </TableHead> </TableRow> </TableHeader> <TableBody> {runs.map((run) => ( <TableRow key={run.date}> <TableCell>{run.date}</TableCell> <TableCell className="text-right tabular-nums"> {run.invoices} </TableCell> <TableCell className="text-right tabular-nums"> {run.suppliers} </TableCell> <TableCell className="text-right tabular-nums"> {run.amount} </TableCell> </TableRow> ))} </TableBody> </Table> </div> );}Link and row menu
One real link in the first cell, a truncating column with max-w-0 w-full, and a labelled menu at the end. The row keeps its hover fill while the menu is open. Owner hides below 768px.
| Report | Owner | Updated | Actions |
|---|---|---|---|
| AP aging by supplier | Priya Raman | Sep 28 | |
| Early-pay discounts captured | Tomás Ferreira | Sep 25 | |
| W-9 coverage | Aisha Bello | Sep 21 |
import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon } from "lucide-react";import Link from "next/link";export function RowActions() { const reports = [ { id: "ap-aging", name: "AP aging by supplier", owner: "Priya Raman", updated: "Sep 28", }, { id: "early-pay", name: "Early-pay discounts captured", owner: "Tomás Ferreira", updated: "Sep 25", }, { id: "w9-coverage", name: "W-9 coverage", owner: "Aisha Bello", updated: "Sep 21", }, ]; return ( <div className="w-full max-w-2xl rounded-xl bg-card px-4 py-2 shadow-border"> <Table aria-label="Reports" className="text-[13px]"> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Report </TableHead> <TableHead className="h-8 text-xs font-normal text-muted-foreground max-md:hidden"> Owner </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Updated </TableHead> <TableHead className="h-8 w-10"> <span className="sr-only">Actions</span> </TableHead> </TableRow> </TableHeader> <TableBody> {reports.map((report) => ( <TableRow key={report.id}> <TableCell className="w-full max-w-0"> <Link href={`/design/components/table#${report.id}`} className="block truncate font-medium outline-none hover:underline focus-visible:underline" > {report.name} </Link> </TableCell> <TableCell className="text-muted-foreground max-md:hidden"> {report.owner} </TableCell> <TableCell className="text-right text-muted-foreground tabular-nums"> {report.updated} </TableCell> <TableCell className="py-1 pr-0"> <DropdownMenu> <Tooltip> <TooltipTrigger render={ <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Options for ${report.name}`} /> } /> } > <MoreHorizontalIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Options</TooltipContent> </Tooltip> <DropdownMenuContent align="end" className="w-44" > <DropdownMenuItem onClick={() => toast.add({ title: `${report.name} duplicated`, }) } > Duplicate </DropdownMenuItem> <DropdownMenuItem onClick={() => toast.add({ title: "Link copied", description: report.name, }) } > Copy link </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" onClick={() => toast.add({ type: "error", title: `${report.name} deleted`, }) } > Delete report </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> ))} </TableBody> </Table> </div> );}Marked row
data-state="selected" fills a row with Well Gray. Use it to mark the viewer in a leaderboard, and say so in text as well.
| # | Approver | Approved | Median time |
|---|---|---|---|
| 1 | Priya Raman | 184 | 2h 10m |
| 2 | Jordan Lee (you) | 151 | 3h 42m |
| 3 | Wen Zhou | 132 | 4h 05m |
| 4 | Aisha Bello | 97 | 5h 18m |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";export function SelectedRow() { const approvers = [ { name: "Priya Raman", approved: 184, median: "2h 10m", you: false }, { name: "Jordan Lee", approved: 151, median: "3h 42m", you: true }, { name: "Wen Zhou", approved: 132, median: "4h 05m", you: false }, { name: "Aisha Bello", approved: 97, median: "5h 18m", you: false }, ]; return ( <div className="w-full max-w-lg rounded-xl bg-card px-4 py-2 shadow-border"> <Table aria-label="Invoice approvals this week" className="text-[13px]" > <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 w-8 text-right text-xs font-normal text-muted-foreground"> # </TableHead> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Approver </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Approved </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Median time </TableHead> </TableRow> </TableHeader> <TableBody> {approvers.map((approver, index) => ( <TableRow key={approver.name} data-state={approver.you ? "selected" : undefined} > <TableCell className="text-right text-muted-foreground tabular-nums"> {index + 1} </TableCell> <TableCell className="font-medium"> {approver.name} {approver.you ? ( <span className="font-normal text-muted-foreground"> {" "} (you) </span> ) : null} </TableCell> <TableCell className="text-right tabular-nums"> {approver.approved} </TableCell> <TableCell className="text-right tabular-nums"> {approver.median} </TableCell> </TableRow> ))} </TableBody> </Table> </div> );}Empty
For a filtered or naturally empty table, keep the headers and span one row that says why it's empty.
| Invoice | Supplier | Amount |
|---|---|---|
| No exceptions. All 212 invoices matched their purchase orders. | ||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";export function EmptyRow() { return ( <div className="w-full max-w-xl rounded-xl bg-card px-4 py-2 shadow-border"> <Table aria-label="Exceptions in the Oct 2 payment run" className="text-[13px]" > <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Invoice </TableHead> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Supplier </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Amount </TableHead> </TableRow> </TableHeader> <TableBody> <TableRow className="hover:bg-transparent"> <TableCell colSpan={3} className="h-20 text-center whitespace-normal text-muted-foreground" > No exceptions. All 212 invoices matched their purchase orders. </TableCell> </TableRow> </TableBody> </Table> </div> );}States#
| State | Supplier | Open invoices |
|---|---|---|
| Rest | Northwind Freight | 42 |
| Hover | Northwind Freight | 42 |
| Menu open | Northwind Freight | 42 |
| Selected | Northwind Freight | 42 |
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@oration/canon/components/table";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { return ( <div className="w-full max-w-xl rounded-xl bg-card px-4 py-2 shadow-border"> <Table aria-label="Row states" className="text-[13px]"> <TableHeader> <TableRow className="hover:bg-transparent"> <TableHead className="h-8 w-28 text-xs font-normal text-muted-foreground"> State </TableHead> <TableHead className="h-8 text-xs font-normal text-muted-foreground"> Supplier </TableHead> <TableHead className="h-8 text-right text-xs font-normal text-muted-foreground"> Open invoices </TableHead> </TableRow> </TableHeader> <TableBody> {rowStates.map((state) => ( <TableRow key={state.label} className={cn( "pointer-events-none", state.className, )} > <TableCell className="text-muted-foreground"> {state.label} </TableCell> <TableCell>Northwind Freight</TableCell> <TableCell className="text-right tabular-nums"> 42 </TableCell> </TableRow> ))} </TableBody> </Table> </div> );}| State | Treatment |
|---|---|
| Rest | Rows on the card or plane, ruled by hairlines. |
| Row hover | Well Gray at 50% under the whole row, 150ms. Opt out on header rows with hover:bg-transparent. |
| Menu open | While a row's menu trigger has aria-expanded, the row keeps the hover fill so you can see which row the menu belongs to. |
| Selected | Set data-state="selected" on a row to fill it Well Gray, for the current user in a leaderboard. It is a mark, not a selection model. |
| Loading | Not drawn by Table. Render SkeletonTable through Data state in its place. |
| Empty | Not drawn by Table. Render Empty state instead of the table, or a single spanning row for a filtered list. |
Behavior#
Tablerenders the container div and the<table>; itsclassNamegoes on the table, not the container. There is no prop for the container.- Cells are
whitespace-nowrap. For a column that should truncate, give its cellmax-w-0 w-fulland atruncatechild, as the reports index does. - The table is
caption-bottom;TableCaptionsits under the rows at 14px Slate Meta. TableHeadandTableCelldrop their right padding when they hold a checkbox, so a checkbox column hugs the edge.- Hide low-priority columns on narrow screens with
max-md:hiddenon the head and each cell, as the reports and customers tables do, before relying on sideways scroll. - Only the row fill transitions (150ms). Nothing moves.
Do and don't#
| Supplier | AP spend |
|---|---|
| Northwind Freight | $1,284,300.00 |
| Halcyon Health | $918,450.00 |
| Supplier | AP spend |
|---|---|
| Northwind Freight | $1,284,300.00 |
| Halcyon Health | $918,450.00 |
| AP aging by supplier | Sep 28 |
| AP aging by supplier | Sep 28 |
<tr> clickable with onClick and nothing else. The row can't be reached with Tab and doesn't announce as a link.Content#
- Headers are short nouns in sentence case: Agent, Handled, AHT, Close date. Spell out an abbreviation in the page or an Info tip the first time it appears.
- Put units in the cell, not the header: 4m 12s, 92%, $18,240.00.
- The header of an actions column is screen-reader text only:
<span className="sr-only">Actions</span>. - Footer rows start with Total and use the same units and precision as the column above.
- When a value is missing, write why in Slate Meta (Not scheduled, No description) rather than leaving it blank.
Accessibility#
- It is a native table, so rows and columns are announced with no ARIA. Name it: pass
aria-labelor render aTableCaption, or pointaria-labelledbyat the card title. TableHeadrenders<th>without ascope. Simple one-row headers are read correctly anyway; addscope="col"when a table has row headers too.- Give each row's menu trigger a name that includes the row, such as Options for Monday containment review, and a tooltip.
- Don't hide a column's only link in a hidden-on-mobile column; the first column must always carry the row's link.
- The selected fill is a Well Gray tint only. Say who the row is in text as well, as the leaderboard does with You.
| Keys | Action |
|---|---|
| Tab | Moves through links and buttons in the cells, row by row. |
| Enter | Follows the focused link. |
| Space | Opens the focused row menu or toggles a checkbox. |
Design tokens#
| Token | Used for |
|---|---|
--border | Row rules and the footer top rule |
--muted | Row hover and open-menu fill at 50%, selected row fill, footer band at 50% |
--foreground | Default header and cell text |
--muted-foreground | The caption; the header color call sites set |
text-sm | Default size; override with text-[13px] |
h-10 | Default header height; cards use h-8 |
API reference#
Table
A scrolling container and the <table>. className applies to the table.
Other props spread onto <table>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after w-full caption-bottom text-sm. Pass text-[13px] for dense tables, table-fixed or a min-w-* for wide ones. |
TableHeader
The <thead>. Rules its rows with a bottom hairline.
Other props spread onto <thead>.
No props of its own.
TableBody
The <tbody>. Removes the rule under the last row.
Other props spread onto <tbody>.
No props of its own.
TableRow
A row with a hairline bottom rule and the hover fill. Keeps the fill while a descendant has aria-expanded.
Other props spread onto <tr>.
| Prop | Type | Default | Description |
|---|---|---|---|
data-state | "selected" | No default | Fills the row with Well Gray. |
TableHead
A header cell: 40px, 8px side padding, medium weight, left-aligned, no wrapping.
Other props spread onto <th>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Cards pass h-8 text-xs font-normal text-muted-foreground; numeric columns add text-right. |
TableCell
A body cell: 8px padding, vertically centered, no wrapping.
Other props spread onto <td>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Numeric cells add text-right tabular-nums; truncating cells add max-w-0 w-full. |
TableCaption
A <caption> under the table, 16px below it, in 14px Slate Meta.
Other props spread onto <caption>.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The defaults disagree with DESIGN.md's tables: headers are 40px, 14px medium Graphite Ink instead of 32px Slate Meta; rows are 14px instead of Body Dense 13px; row hover is Well Gray at 50% instead of Row Mist; selected is Well Gray instead of the 6% indigo tint. h-8 text-xs font-normal text-muted-foreground is repeated on more than 30 TableHeads in the product.
Seven product tables pass className="text-13". Table merges classes with the cn package, which reads text-13 as a color, so text-sm is kept and wins in the stylesheet: those tables still render at 14px. Use text-[13px].
Numeric alignment isn't built in. There is no align prop, so every numeric column repeats text-right on the head and text-right tabular-nums on each cell.
TableHead renders <th> with no scope, and there's no prop for the scroll container, so it can't be named, given a max height or a sticky header.
Selection uses Radix's data-state="selected", while Data grid uses data-selected. The file is also marked "use client" though it has no client code.