Card table
A table set inside a card, with an empty row and compact headers.
API keys
Keys act as Cedarline. Rotate any key that leaves your team.
| Name | Key | Created | Last used | Actions |
|---|---|---|---|---|
| NetSuite sync | sk_live_…4f2a | Aug 12, 2026 | 2 minutes ago | |
| Bank file export | sk_live_…9c71 | Jun 3, 2026 | Sep 25, 2026 | |
| Sandbox testing | sk_test_…0b38 | Sep 21, 2026 | Never |
import { Button } from "@oration/canon/components/button";import { CardTable, Td, Th } from "@oration/canon/components/card-table";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, PlusIcon } from "lucide-react";export function Hero() { const keys = [ { id: "key-erp", name: "NetSuite sync", token: "sk_live_…4f2a", created: "Aug 12, 2026", lastUsed: "2 minutes ago", }, { id: "key-bank", name: "Bank file export", token: "sk_live_…9c71", created: "Jun 3, 2026", lastUsed: "Sep 25, 2026", }, { id: "key-sandbox", name: "Sandbox testing", token: "sk_test_…0b38", created: "Sep 21, 2026", lastUsed: "Never", }, ]; return ( <div className="flex w-full max-w-2xl flex-col gap-3"> <div className="flex items-center justify-between gap-4"> <div> <h3 className="text-sm font-semibold">API keys</h3> <p className="text-13 text-muted-foreground"> Keys act as Cedarline. Rotate any key that leaves your team. </p> </div> <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ type: "success", title: "API key created", description: "Copy it now; it won't be shown again.", }) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Create key </Button> </div> <CardTable label="API keys"> <thead> <tr> <Th>Name</Th> <Th>Key</Th> <Th>Created</Th> <Th>Last used</Th> <Th className="w-12"> <span className="sr-only">Actions</span> </Th> </tr> </thead> <tbody> {keys.map((key) => ( <tr key={key.id}> <Td className="font-medium">{key.name}</Td> <Td className="font-mono text-xs text-muted-foreground"> {key.token} </Td> <Td className="text-muted-foreground"> {key.created} </Td> <Td className="text-muted-foreground"> {key.lastUsed} </Td> <Td className="pr-2"> <DropdownMenu> <Tooltip> <TooltipTrigger render={ <DropdownMenuTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Options for ${key.name}`} /> } /> } > <MoreHorizontalIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Options</TooltipContent> </Tooltip> <DropdownMenuContent align="end" className="w-40" > <DropdownMenuItem onClick={() => toast.add({ title: `${key.name} key rotated`, }) } > Rotate key </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem variant="destructive" onClick={() => toast.add({ type: "error", title: `${key.name} key revoked`, }) } > Revoke key </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </Td> </tr> ))} </tbody> </CardTable> </div> );}Usage#
Card table is a table that is its own card: a 12px-corner Card White surface with hairline lift, 36px Slate Meta headers, 48px rows on hairline rules and an empty row. It is the table of every settings page in Oration (members, API keys, webhooks, disposition codes), where the table is the content of a section rather than a block inside a bigger card. The thing to get right is placement: it already is the card, so it never goes inside another one.
When to use
- For the main table of a settings section: members, API keys, webhooks, approval thresholds.
- For editable rows where each cell holds an input, a select or a switch.
- For a short list of records with a status and a row menu, where selection and sorting aren't needed.
- When the table needs a friendly empty row that keeps its headers visible.
When not to use
- For a table inside a card that already has a title and other content. Use the plain table so you don't nest cards. Use Table
- For a record list with selection, pinned columns, resize or bulk actions. Use Data grid
- For a list of rows with a title, description and a trailing action but no columns. Use Item
- For label and value pairs that describe one record. Use Meta line
The Tint Well Rule
The Tabular Figures Rule
tabular-nums, and numeric columns align right, header included.The Machine Mono Rule
Anatomy#
| Member | Role | Last active |
|---|---|---|
| Priya Raman | Admin | Today |
| Tomás Ferreira | Approver | Sep 25 |
- Card. A Card White surface with 12px corners and hairline lift. Clips its corners and scrolls sideways below
minWidth. - Header cell.
Th: 36px, 16px side padding, 12px medium Slate Meta,scope="col", a hairline below. - Body cell.
Td: 48px tall, 16px side padding, 13px, vertically centered, a hairline below. - Last row. The last body row drops its rule so the card's edge closes the table.
Examples#
Editable rows
Inputs sit in cells at 32px with py-2 on the cell. Each one is named after its row, and removing the last row shows the empty row.
| Amount over | Approver | Remove |
|---|---|---|
import { Button } from "@oration/canon/components/button";import { CardTable, CardTableEmpty, Td, Th } from "@oration/canon/components/card-table";import { Input } from "@oration/canon/components/input";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { PlusIcon, Trash2Icon } from "lucide-react";import * as React from "react";export function EditableRows() { const [rules, setRules] = React.useState([ { id: "r1", over: "5,000", approver: "Jordan Lee" }, { id: "r2", over: "25,000", approver: "Priya Raman" }, { id: "r3", over: "100,000", approver: "Maya Okafor" }, ]); return ( <div className="w-full max-w-2xl"> <CardTable label="Approval thresholds" minWidth="30rem"> <thead> <tr> <Th className="w-40">Amount over</Th> <Th>Approver</Th> <Th className="w-12"> <span className="sr-only">Remove</span> </Th> </tr> </thead> <tbody> {rules.map((rule, index) => ( <tr key={rule.id}> <Td className="py-2"> <Input aria-label={`Threshold ${index + 1} amount`} inputMode="decimal" className="h-8 text-right tabular-nums" value={rule.over} onChange={(event) => setRules((current) => current.map((r) => r.id === rule.id ? { ...r, over: event.target .value, } : r, ), ) } /> </Td> <Td className="py-2"> <Input aria-label={`Threshold ${index + 1} approver`} className="h-8" value={rule.approver} onChange={(event) => setRules((current) => current.map((r) => r.id === rule.id ? { ...r, approver: event.target .value, } : r, ), ) } /> </Td> <Td className="pr-2"> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove threshold ${index + 1}`} onClick={() => setRules((current) => current.filter( (r) => r.id !== rule.id, ), ) } /> } > <Trash2Icon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Remove</TooltipContent> </Tooltip> </Td> </tr> ))} {rules.length === 0 ? ( <CardTableEmpty colSpan={3} className="h-24 text-center text-muted-foreground" > Every invoice is auto-approved. Add a threshold to require review. </CardTableEmpty> ) : null} </tbody> </CardTable> <Button type="button" variant="ghost" size="sm" className="mt-2" onClick={() => setRules((current) => [ ...current, { id: `r${Date.now()}`, over: "", approver: "" }, ]) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add threshold </Button> </div> );}Empty
CardTableEmpty spans every column. Keep the headers, say what's missing and offer the next step.
| Endpoint | Events | Status |
|---|---|---|
No webhooks yet Send invoice and payment events to your ERP or data warehouse. | ||
import { Button } from "@oration/canon/components/button";import { CardTable, CardTableEmpty, Th } from "@oration/canon/components/card-table";import { toast } from "@oration/canon/components/toast";import { WebhookIcon } from "lucide-react";export function EmptyRow() { return ( <div className="w-full max-w-2xl"> <CardTable label="Webhooks"> <thead> <tr> <Th>Endpoint</Th> <Th>Events</Th> <Th>Status</Th> </tr> </thead> <tbody> <CardTableEmpty colSpan={3} className="h-36 text-center"> <div className="flex flex-col items-center gap-2"> <WebhookIcon aria-hidden="true" className="size-4 text-muted-foreground" /> <p className="text-sm font-medium"> No webhooks yet </p> <p className="text-13 text-muted-foreground"> Send invoice and payment events to your ERP or data warehouse. </p> <Button type="button" variant="outline" size="sm" className="mt-1" onClick={() => toast.add({ title: "New webhook" }) } > Add endpoint </Button> </div> </CardTableEmpty> </tbody> </CardTable> </div> );}Wide tables
minWidth="44rem" keeps four columns readable; on narrower screens the table scrolls sideways inside its card.
| Member | Role | Entity | Status |
|---|---|---|---|
| MOMaya Okaformaya@cedarline.com | Owner | Cedarline Inc. | Active |
| ABAisha Belloaisha@cedarline.com | Approver | Cedarline Canada | Active |
| WZWen Zhouwen@cedarline.com | AP clerk | Cedarline Inc. | Invited |
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { CardTable, Td, Th } from "@oration/canon/components/card-table";import { StatusLabel } from "@oration/canon/components/status-dot";import { Tag } from "@oration/canon/components/tag";export function WideTable() { const members = [ { name: "Maya Okafor", initials: "MO", email: "maya@cedarline.com", role: "Owner", entity: "Cedarline Inc.", status: "Active", }, { name: "Aisha Bello", initials: "AB", email: "aisha@cedarline.com", role: "Approver", entity: "Cedarline Canada", status: "Active", }, { name: "Wen Zhou", initials: "WZ", email: "wen@cedarline.com", role: "AP clerk", entity: "Cedarline Inc.", status: "Invited", }, ]; return ( <div className="w-full max-w-xl"> <CardTable label="Members" minWidth="44rem"> <thead> <tr> <Th>Member</Th> <Th>Role</Th> <Th>Entity</Th> <Th>Status</Th> </tr> </thead> <tbody> {members.map((member) => ( <tr key={member.email}> <Td> <span className="flex items-center gap-2.5"> <Avatar size="sm"> <AvatarFallback> {member.initials} </AvatarFallback> </Avatar> <span className="min-w-0"> <span className="block truncate font-medium"> {member.name} </span> <span className="block truncate text-xs text-muted-foreground"> {member.email} </span> </span> </span> </Td> <Td> <Tag color={ member.role === "Owner" ? "indigo" : "gray" } > {member.role} </Tag> </Td> <Td className="text-muted-foreground"> {member.entity} </Td> <Td> <StatusLabel tone={ member.status === "Active" ? "success" : "warning" } > {member.status} </StatusLabel> </Td> </tr> ))} </tbody> </CardTable> </div> );}States#
| Supplier | Open |
|---|---|
| Northwind Freight | 42 |
| Halcyon Health | 18 |
| Supplier | Open |
|---|---|
| No suppliers on hold | |
import { CardTable, CardTableEmpty, Td, Th } from "@oration/canon/components/card-table";export function StatesMatrix() { return ( <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2"> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground">Rows</span> <CardTable label="Rows at rest" minWidth="16rem"> <thead> <tr> <Th>Supplier</Th> <Th className="text-right">Open</Th> </tr> </thead> <tbody> <tr> <Td>Northwind Freight</Td> <Td className="text-right tabular-nums">42</Td> </tr> <tr> <Td>Halcyon Health</Td> <Td className="text-right tabular-nums">18</Td> </tr> </tbody> </CardTable> </div> <div className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground">Empty</span> <CardTable label="Empty table" minWidth="16rem"> <thead> <tr> <Th>Supplier</Th> <Th className="text-right">Open</Th> </tr> </thead> <tbody> <CardTableEmpty colSpan={2} className="h-24 text-center text-muted-foreground" > No suppliers on hold </CardTableEmpty> </tbody> </CardTable> </div> </div> );}| State | Treatment |
|---|---|
| Rest | Rows on Card White, ruled by hairlines. Rows have no hover fill. |
| Editing | Inputs and selects sit in cells at 32px with py-2 on the cell, so the row grows to fit. |
| Empty | CardTableEmpty spans the columns with one row; set its height and centering, and say why it's empty. |
| Overflow | Below minWidth (36rem by default) the table scrolls sideways inside the card. |
| Loading | Not drawn. Render a skeleton or Data state in its place. |
Behavior#
CardTablerenders the card, a scroll container and a<table>; you write<thead>,<tbody>and<tr>yourself and fill them withThandTd.labelnames the table witharia-label. It isn't drawn; put the visible title in the section header.minWidthsets the table's minimum width. Narrower than that, the table scrolls sideways inside the card instead of squeezing its columns.- The table uses separate borders, so rules are drawn on cells and the last body row's cells drop theirs.
- Nothing moves or transitions.
Do and don't#
| Member | Role |
|---|---|
| Priya Raman | Admin |
Members
| Member | Role |
|---|---|
| Priya Raman | Admin |
| Endpoint | Status |
|---|---|
| No webhooks yet. Add an endpoint to send payment events. | |
| Endpoint | Status |
|---|
Content#
- Headers are short sentence-case nouns: Name, Last used, Amount over.
- An actions column's header is screen-reader text only: Actions or Remove.
- Empty rows say what's missing and what happens next: No webhooks yet. Add an endpoint to send payment events.
- Write "Never" or "Not used yet" for a missing date rather than leaving a blank or a dash.
- Masked keys keep the prefix and last four:
sk_live_…4f2a.
Accessibility#
- It is a native table with
aria-labelfromlabel, andThsetsscope="col", so screen readers announce headers with each cell. - Give every input in a cell its own
aria-labelthat includes the row, such as Threshold 2 amount. - Row menu and remove buttons are icon buttons: name them after the row and add a tooltip.
- The empty row is a real row, so it's read in the table; keep its message short.
- Horizontal overflow scrolls with a trackpad or Shift and the wheel; nothing in the card is focusable just to scroll, so keep the first column meaningful at narrow widths.
| Keys | Action |
|---|---|
| Tab | Moves through inputs, links and buttons in reading order. |
| Enter | Activates the focused button or link. |
Design tokens#
| Token | Used for |
|---|---|
--card | Card White surface |
shadow-border | Hairline lift around the card |
--radius-xl | 12px card corners |
--border | Header and row rules |
--muted-foreground | Header text |
text-13 | Body cells |
API reference#
CardTable
The card, the scroll container and the <table>.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | Accessible name of the table. |
childrenRequired | React.ReactNode | No default | <thead> and <tbody>. |
minWidth | string | "36rem" | Minimum table width before it scrolls sideways. |
className | string | No default | Applied to the card. |
Th
A header cell with scope="col".
Other props spread onto <th>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Widths such as w-40, or text-right for numbers. |
Td
A 48px body cell.
Other props spread onto <td>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | py-2 around inputs, text-right tabular-nums for numbers, font-mono text-xs for keys. |
CardTableEmpty
A row with one cell that spans the table.
| Prop | Type | Default | Description |
|---|---|---|---|
colSpanRequired | number | No default | The number of columns to span. |
childrenRequired | React.ReactNode | No default | The message, and an action if there is one. |
className | string | No default | Applied to the cell. Set a height and text-center. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The only importer is settings-kit.tsx, which re-exports it as SettingsTable and SettingsTableEmpty; 19 settings files use those names, so a search for CardTable finds almost nothing.
CardTableEmpty has no default height, centering or color. Every call site sets h-24 text-center text-muted-foreground or similar.
There's no numeric alignment option and no shared base with Table: headers here are 36px 12px medium, Table's product convention is 32px 12px regular, and both differ from Data grid's 32px 13px medium.
label only feeds aria-label. There's no caption or title slot, so the visible section title isn't tied to the table unless you wire aria-labelledby yourself, which CardTable doesn't accept.
The scroll container shows no edge fade when columns are clipped, unlike the Scroll Edge Rule for tab rows and pinned grid columns.