Skip to content

Table

A semantic table for small, static data sets inside cards and settings.

Status
Stable
Category
Data display
Adoption
Not used yet
import { Table } from "@oration/canon/components/table";
packages/canon/src/components/table.tsx

Top suppliers by AP spend

September 2026
SupplierInvoicesAP spendDays to pay
Northwind Freight412$1,284,300.0028
Halcyon Health296$918,450.0031
Orchard Street Market138$402,120.0024
Meridian Parts Co.121$356,980.0035
Cobalt Ridge Foods97$241,760.0029
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

Counts, amounts, rates and durations are set in tabular figures, and numeric columns align right, header included. Table does neither for you: add text-right tabular-nums to the head and every cell in the column.

The Thirteen-Fourteen Rule

Rows in a card or panel are dense UI at 13px, and headers are meta at 12px. The defaults are 14px, so pass text-[13px] to Table and set the head size yourself.

The Tint Well Rule

A table inside a card is part of the card, not a card of its own. Don't wrap it in a second bordered or shadowed box; let the card's padding or a hairline divide it.

Anatomy#

Payment runInvoicesAmount
Friday, Oct 2212$684,210.00
Friday, Sep 25198$602,775.00
Total410$1,286,985.00
  1. Container. A relative w-full overflow-x-auto div around the <table>, so wide tables scroll sideways inside their card.
  2. Header row. TableHeader and TableHead. 40px by default; in cards set it to 32px, 12px, regular weight, Slate Meta.
  3. Row. TableRow with a hairline bottom rule. The last row in the body drops its rule.
  4. Numeric cell. A TableCell with text-right tabular-nums, matched by the same classes on its head.
  5. 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 dateInvoicesSuppliersAmount
Friday, Oct 221248$684,210.00
Friday, Sep 2519845$602,775.00
Friday, Sep 1817641$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.

ReportOwnerUpdatedActions
AP aging by supplierPriya RamanSep 28
Early-pay discounts capturedTomás FerreiraSep 25
W-9 coverageAisha BelloSep 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.

#ApproverApprovedMedian time
1Priya Raman1842h 10m
2Jordan Lee (you)1513h 42m
3Wen Zhou1324h 05m
4Aisha Bello975h 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.

InvoiceSupplierAmount
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#

StateSupplierOpen invoices
RestNorthwind Freight42
HoverNorthwind Freight42
Menu openNorthwind Freight42
SelectedNorthwind Freight42
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>    );}
States
StateTreatment
RestRows on the card or plane, ruled by hairlines.
Row hoverWell Gray at 50% under the whole row, 150ms. Opt out on header rows with hover:bg-transparent.
Menu openWhile a row's menu trigger has aria-expanded, the row keeps the hover fill so you can see which row the menu belongs to.
SelectedSet 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.
LoadingNot drawn by Table. Render SkeletonTable through Data state in its place.
EmptyNot drawn by Table. Render Empty state instead of the table, or a single spanning row for a filtered list.

Behavior#

  • Table renders the container div and the <table>; its className goes 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 cell max-w-0 w-full and a truncate child, as the reports index does.
  • The table is caption-bottom; TableCaption sits under the rows at 14px Slate Meta.
  • TableHead and TableCell drop 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:hidden on 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#

SupplierAP spend
Northwind Freight$1,284,300.00
Halcyon Health$918,450.00
Do. In a card, set rows to 13px and headers to 32px, 12px Slate Meta, and right-align numbers in tabular figures.
SupplierAP spend
Northwind Freight$1,284,300.00
Halcyon Health$918,450.00
Don't. Ship the defaults next to dense UI: 40px, 14px headers in full ink, with numbers left-aligned.
Do. Put one real link in the first cell of each row, and keep row actions in a labelled menu at the end.
AP aging by supplierSep 28
Don't. Make the <tr> clickable with onClick and nothing else. The row can't be reached with Tab and doesn't announce as a link.
Do. Keep Table for a few dozen read-only rows inside a card.
Don't. Grow it into a list page with checkboxes and sticky headers. That is Data grid, and it already solves pinning, selection and bulk actions.

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-label or render a TableCaption, or point aria-labelledby at the card title.
  • TableHead renders <th> without a scope. Simple one-row headers are read correctly anyway; add scope="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.
Keyboard interactions
KeysAction
TabMoves through links and buttons in the cells, row by row.
EnterFollows the focused link.
SpaceOpens the focused row menu or toggles a checkbox.

Design tokens#

Design tokens
TokenUsed for
--borderRow rules and the footer top rule
--mutedRow hover and open-menu fill at 50%, selected row fill, footer band at 50%
--foregroundDefault header and cell text
--muted-foregroundThe caption; the header color call sites set
text-smDefault size; override with text-[13px]
h-10Default 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>.

Props of Table
PropTypeDefaultDescription
classNamestringNo defaultMerged 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.

TableFooter

The <tfoot>: a top hairline, Well Gray at 50% and medium weight.

Other props spread onto <tfoot>.

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

Props of TableRow
PropTypeDefaultDescription
data-state"selected"No defaultFills the row with Well Gray.

TableHead

A header cell: 40px, 8px side padding, medium weight, left-aligned, no wrapping.

Other props spread onto <th>.

Props of TableHead
PropTypeDefaultDescription
classNamestringNo defaultCards 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>.

Props of TableCell
PropTypeDefaultDescription
classNamestringNo defaultNumeric 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.