Skip to content

Card table

A table set inside a card, with an empty row and compact headers.

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

API keys

Keys act as Cedarline. Rotate any key that leaves your team.

NameKeyCreatedLast usedActions
NetSuite syncsk_live_…4f2aAug 12, 20262 minutes ago
Bank file exportsk_live_…9c71Jun 3, 2026Sep 25, 2026
Sandbox testingsk_test_…0b38Sep 21, 2026Never
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

Card table is a card. Don't put it inside another card or panel; set it directly in the settings section, with the section's title and actions above it.

The Tabular Figures Rule

Counts, amounts and dates that get compared are set in tabular-nums, and numeric columns align right, header included.

The Machine Mono Rule

Keys, tokens and IDs in a cell are Geist Mono at 12px. Names, dates and roles stay in Geist Sans.

Anatomy#

MemberRoleLast active
Priya RamanAdminToday
Tomás FerreiraApproverSep 25
  1. Card. A Card White surface with 12px corners and hairline lift. Clips its corners and scrolls sideways below minWidth.
  2. Header cell. Th: 36px, 16px side padding, 12px medium Slate Meta, scope="col", a hairline below.
  3. Body cell. Td: 48px tall, 16px side padding, 13px, vertically centered, a hairline below.
  4. 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 overApproverRemove
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.

EndpointEventsStatus

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.

MemberRoleEntityStatus
MOMaya Okaformaya@cedarline.comOwnerCedarline Inc.Active
ABAisha Belloaisha@cedarline.comApproverCedarline CanadaActive
WZWen Zhouwen@cedarline.comAP clerkCedarline 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#

Rows
SupplierOpen
Northwind Freight42
Halcyon Health18
Empty
SupplierOpen
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>    );}
States
StateTreatment
RestRows on Card White, ruled by hairlines. Rows have no hover fill.
EditingInputs and selects sit in cells at 32px with py-2 on the cell, so the row grows to fit.
EmptyCardTableEmpty spans the columns with one row; set its height and centering, and say why it's empty.
OverflowBelow minWidth (36rem by default) the table scrolls sideways inside the card.
LoadingNot drawn. Render a skeleton or Data state in its place.

Behavior#

  • CardTable renders the card, a scroll container and a <table>; you write <thead>, <tbody> and <tr> yourself and fill them with Th and Td.
  • label names the table with aria-label. It isn't drawn; put the visible title in the section header.
  • minWidth sets 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#

MemberRole
Priya RamanAdmin
Do. Set the card table straight into the settings section, under the section title.

Members

MemberRole
Priya RamanAdmin
Don't. Wrap it in another card with its own title. That's a card in a card, with two edges and doubled padding.
EndpointStatus
No webhooks yet. Add an endpoint to send payment events.
Do. Keep the headers and say in an empty row why there's nothing yet and what to do.
EndpointStatus
Don't. Render a header with no rows. It reads as broken or still loading.

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-label from label, and Th sets scope="col", so screen readers announce headers with each cell.
  • Give every input in a cell its own aria-label that 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.
Keyboard interactions
KeysAction
TabMoves through inputs, links and buttons in reading order.
EnterActivates the focused button or link.

Design tokens#

Design tokens
TokenUsed for
--cardCard White surface
shadow-borderHairline lift around the card
--radius-xl12px card corners
--borderHeader and row rules
--muted-foregroundHeader text
text-13Body cells

API reference#

CardTable

The card, the scroll container and the <table>.

Props of CardTable
PropTypeDefaultDescription
labelRequiredstringNo defaultAccessible name of the table.
childrenRequiredReact.ReactNodeNo default<thead> and <tbody>.
minWidthstring"36rem"Minimum table width before it scrolls sideways.
classNamestringNo defaultApplied to the card.

Th

A header cell with scope="col".

Other props spread onto <th>.

Props of Th
PropTypeDefaultDescription
classNamestringNo defaultWidths such as w-40, or text-right for numbers.

Td

A 48px body cell.

Other props spread onto <td>.

Props of Td
PropTypeDefaultDescription
classNamestringNo defaultpy-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.

Props of CardTableEmpty
PropTypeDefaultDescription
colSpanRequirednumberNo defaultThe number of columns to span.
childrenRequiredReact.ReactNodeNo defaultThe message, and an action if there is one.
classNamestringNo defaultApplied 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.