Skip to content

Card

The raised container: 12px corners, a hairline lift and 16px of padding.

Status
Beta
Category
Layout
Adoption
Not used yet
import { Card } from "@oration/canon/components/card";
packages/canon/src/components/card.tsx

Payment runs this week

import { Card } from "@oration/canon/components/card";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const headingId = React.useId();    const runs = [        {            id: "run-0930",            name: "Weekly ACH run",            when: "Wed, Sep 30",            invoices: 86,            amount: "$412,880.50",            status: { tone: "warning" as const, label: "Awaiting approval" },        },        {            id: "run-1002",            name: "Friday wire run",            when: "Fri, Oct 2",            invoices: 212,            amount: "$1,284,310.00",            status: { tone: "neutral" as const, label: "Scheduled" },        },        {            id: "run-0925",            name: "Card payouts",            when: "Fri, Sep 25",            invoices: 41,            amount: "$58,902.14",            status: { tone: "success" as const, label: "Sent" },        },    ];    return (        <Card            className="w-full max-w-md"            render={<section aria-labelledby={headingId} />}        >            <div className="flex items-baseline justify-between px-4 pt-4 pb-2">                <h2                    id={headingId}                    className="text-sm font-semibold text-foreground"                >                    Payment runs this week                </h2>                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "All payment runs",                            description: "Opens the payment runs list.",                        })                    }                    className="rounded-sm text-xs text-muted-foreground hover:text-foreground"                >                    All payment runs                </button>            </div>            <ul className="flex flex-col px-2 pb-2">                {runs.map((run) => (                    <li key={run.id}>                        <button                            type="button"                            onClick={() =>                                toast.add({                                    title: run.name,                                    description: `${run.invoices} invoices, ${run.amount}.`,                                })                            }                            className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40"                        >                            <span className="flex min-w-0 flex-1 flex-col">                                <span className="truncate text-13 font-medium text-foreground">                                    {run.name}                                </span>                                <span className="text-xs text-muted-foreground tabular-nums">                                    {run.when}, {run.invoices} invoices                                </span>                            </span>                            <span className="flex shrink-0 flex-col items-end gap-0.5">                                <span className="text-13 font-medium text-foreground tabular-nums">                                    {run.amount}                                </span>                                <StatusLabel                                    tone={run.status.tone}                                    className="text-xs"                                >                                    {run.status.label}                                </StatusLabel>                            </span>                        </button>                    </li>                ))}            </ul>        </Card>    );}

Usage#

A card is the raised container of the ledger: Card White with 12px corners and 16px of padding, lifted off the plane by the hairline lift (shadow-border) rather than a border. It groups one topic on a document page, such as a Home rail widget, a report panel or a record summary. Build it with Card, which draws the surface (rounded-xl bg-card shadow-border) and leaves padding to you: p-4 for a document card, none for a divide-y list or a toolbar-and-table frame. Swap the element with render, such as render={<section aria-labelledby={id} />} or a Next Link with an href. The mistake people make is nesting: a region inside a card is a 70% Well Gray tint well or a hairline split, never a second card.

When to use

  • To group one topic on a document page: a Home rail widget, a report panel, a record summary.
  • To hold a short list with its own header and a quiet destination link, like Callbacks due today with All callbacks.
  • As the frame around a toolbar and table on a document page, with overflow-hidden so rows reach the 12px corners.
  • As an interactive tile that opens something, such as an agent template, lifting to the hover shadow.

When not to use

  • For a sub-region inside a card. That is a tint well. Use Well
  • For headline metrics. Show them as a stat strip, not a grid of big-number cards. Use Inline stat strip
  • For an option someone selects in a form. Use Choice card
  • For a settings group of labelled rows and controls. Use Settings section
  • For content that floats above the page, such as a menu or a preview. Use Popover
  • For a list or grid with nothing in it yet. Use Empty

The Hairline-and-Lift Rule

A raised surface takes its edge and its lift from one composite shadow, shadow-border, never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.

The Tint Well Rule

Inside a card, a sub-region is a Well Gray tint at 70% with a 10px radius (rounded-[10px] bg-muted/70), never a second bordered or shadowed card.

No nested cards

A card never sits inside a card. If the inner block needs its own edge, it is a well; if it needs its own lift, it belongs beside the card, not in it.

No hero-metric grids

Don't tile big-number cards with tiny labels and trend chips. Metrics are stat strips, rows, tables or real charts, usually inside one card.

Anatomy#

Remittance

Edit

Sent to Northwind Freight with each payment.

INV-20931, $18,420.00
Save
  1. Surface. rounded-xl bg-card shadow-border: Card White, 12px corners and the hairline lift. No CSS border.
  2. Header. A Title (14px, weight 600) on the left and a 12px Slate Meta link or quiet action on the right, on a shared baseline (items-baseline).
  3. Content. 16px padding and 13px copy. Lists inset 8px (px-2 pb-2) with 8px-corner rows that fill Well Gray on hover.
  4. Tint well. A sub-region at rounded-[10px] bg-muted/70, 12px by 8 to 10px of padding, for grouped figures or a note.
  5. Footer. Optional. A hairline top and a Well Gray band at 50% for the card's actions, flush with the bottom corners.

Examples#

The recipe

Card with p-4 and render={<section aria-labelledby={id} />}. The surface carries no padding, so each card picks its own. The header pairs a 14px Title with a 12px Slate Meta link on one baseline.

Remittance email

Suppliers get an itemized remittance within five minutes of each payment run, from payments@cedarline.io.

On for all suppliers
import { Card } from "@oration/canon/components/card";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Recipe() {    const headingId = React.useId();    return (        <Card            className="flex w-full max-w-sm flex-col gap-3 p-4"            render={<section aria-labelledby={headingId} />}        >            <div className="flex items-baseline justify-between gap-4">                <h3                    id={headingId}                    className="text-sm font-semibold text-foreground"                >                    Remittance email                </h3>                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "Remittance template",                            description: "Opens the template editor.",                        })                    }                    className="rounded-sm text-xs text-muted-foreground hover:text-foreground"                >                    Edit template                </button>            </div>            <p className="text-13 text-pretty text-muted-foreground">                Suppliers get an itemized remittance within five minutes of each                payment run, from payments@cedarline.io.            </p>            <StatusLabel tone="success" className="text-[13px]">                On for all suppliers            </StatusLabel>        </Card>    );}

A list inside a card

The card drops its bottom padding and the list insets 8px, so each row's Well Gray hover sits concentric inside the 12px corners.

Callbacks due today

import { Card } from "@oration/canon/components/card";import { toast } from "@oration/canon/components/toast";import { PhoneOutgoingIcon } from "lucide-react";import * as React from "react";export function WithList() {    const headingId = React.useId();    const callbacks = [        {            id: "cb-1",            who: "Halcyon Supply",            why: "Missing remittance for INV-20877",            due: "10:30 AM",        },        {            id: "cb-2",            who: "Orchard Street Bakery",            why: "Update bank details",            due: "11:15 AM",        },        {            id: "cb-3",            who: "Northwind Freight",            why: "Short-paid invoice dispute",            due: "2:00 PM",        },    ];    return (        <Card            className="w-full max-w-sm"            render={<section aria-labelledby={headingId} />}        >            <div className="flex items-baseline justify-between px-4 pt-4 pb-2">                <h3                    id={headingId}                    className="text-sm font-semibold text-foreground"                >                    Callbacks due today                </h3>                <button                    type="button"                    onClick={() =>                        toast.add({                            title: "All callbacks",                            description: "Opens Contact Center callbacks.",                        })                    }                    className="rounded-sm text-xs text-muted-foreground hover:text-foreground"                >                    All callbacks                </button>            </div>            <ul className="flex flex-col px-2 pb-2">                {callbacks.map((callback) => (                    <li key={callback.id}>                        <button                            type="button"                            onClick={() =>                                toast.add({                                    title: `Calling ${callback.who}`,                                    description: callback.why,                                })                            }                            className="group flex w-full items-center gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40"                        >                            <span className="w-14 shrink-0 text-xs text-muted-foreground tabular-nums">                                {callback.due}                            </span>                            <span className="flex min-w-0 flex-1 flex-col">                                <span className="truncate text-13 font-medium text-foreground">                                    {callback.who}                                </span>                                <span className="truncate text-xs text-muted-foreground">                                    {callback.why}                                </span>                            </span>                            <PhoneOutgoingIcon                                aria-hidden="true"                                className="size-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"                            />                        </button>                    </li>                ))}            </ul>        </Card>    );}

Tint wells

Sub-regions are 70% Well Gray wells at 10px corners: grouped figures in one, a note in another. Never a card inside the card.

INV-20931

Northwind Freight
Amount
$18,420.00
Paid on
Sep 25
Method
ACH

Tomás confirmed the short-pay with Northwind on the Sep 24 call. The $1,200 credit posts on the Friday run.

import { Card } from "@oration/canon/components/card";import { FileTextIcon } from "lucide-react";import * as React from "react";export function TintWells() {    const headingId = React.useId();    return (        <Card            className="flex w-full max-w-md flex-col gap-3 p-4"            render={<section aria-labelledby={headingId} />}        >            <div className="flex items-baseline justify-between gap-4">                <h3                    id={headingId}                    className="text-sm font-semibold text-foreground"                >                    INV-20931                </h3>                <span className="text-xs text-muted-foreground">                    Northwind Freight                </span>            </div>            <dl className="grid grid-cols-3 gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13">                <div className="flex flex-col gap-0.5">                    <dt className="text-xs text-muted-foreground">Amount</dt>                    <dd className="font-medium text-foreground tabular-nums">                        $18,420.00                    </dd>                </div>                <div className="flex flex-col gap-0.5">                    <dt className="text-xs text-muted-foreground">Paid on</dt>                    <dd className="font-medium text-foreground tabular-nums">                        Sep 25                    </dd>                </div>                <div className="flex flex-col gap-0.5">                    <dt className="text-xs text-muted-foreground">Method</dt>                    <dd className="font-medium text-foreground">ACH</dd>                </div>            </dl>            <div className="flex gap-2.5 rounded-[10px] bg-muted/70 px-3 py-2.5">                <FileTextIcon                    aria-hidden="true"                    className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"                />                <p className="text-13 text-pretty text-foreground">                    Tomás confirmed the short-pay with Northwind on the Sep 24                    call. The $1,200 credit posts on the Friday run.                </p>            </div>        </Card>    );}

Interactive cards

interactive adds the lift to shadow-border-hover on hover and the focus ring when the stretched [data-overlay] button has keyboard focus. One stretched button per card carries the name.

  • Calls suppliers with a missing W-9 and sends the form.

  • Tells suppliers what was paid and when it lands.

  • Takes the invoice number and reason, then opens a ticket.

import { Card } from "@oration/canon/components/card";import { toast } from "@oration/canon/components/toast";import { FileTextIcon, ReceiptTextIcon, ShieldCheckIcon } from "lucide-react";export function Interactive() {    const templates = [        {            id: "w9",            title: "Overdue W-9 collection",            description:                "Calls suppliers with a missing W-9 and sends the form.",            icon: ShieldCheckIcon,        },        {            id: "confirm",            title: "Payment-run confirmation",            description: "Tells suppliers what was paid and when it lands.",            icon: ReceiptTextIcon,        },        {            id: "dispute",            title: "Invoice dispute intake",            description:                "Takes the invoice number and reason, then opens a ticket.",            icon: FileTextIcon,        },    ];    return (        <ul className="grid w-full gap-3 sm:grid-cols-3">            {templates.map((template) => (                <Card                    key={template.id}                    interactive                    className="relative flex flex-col gap-3 p-4"                    render={<li />}                >                    <span className="flex size-8 items-center justify-center rounded-[10px] bg-muted text-muted-foreground">                        <template.icon aria-hidden="true" className="size-4" />                    </span>                    <div className="flex flex-col gap-1">                        <h3 className="text-13 font-medium text-foreground">                            <button                                type="button"                                data-overlay                                onClick={() =>                                    toast.add({                                        type: "success",                                        title: "Template added",                                        description: `${template.title} is ready to edit.`,                                    })                                }                                className="text-left outline-none after:absolute after:inset-0 after:rounded-xl"                            >                                {template.title}                            </button>                        </h3>                        <p className="text-xs text-pretty text-muted-foreground">                            {template.description}                        </p>                    </div>                </Card>            ))}        </ul>    );}

Card parts

CardHeader, CardTitle, CardAction, CardContent and CardFooter on a card without padding, at the default and sm sizes. The parts pad themselves with --card-spacing.

Friday wire run

212 invoices to 48 suppliers on Oct 2 at 2:00 PM CT.

Total
$1,284,310.00
Suppliers
48
Held
3 missing W-9

Card payouts

Sent Sep 25, 41 invoices.

All 41 remittances delivered
import { Button } from "@oration/canon/components/button";import {  Card,  CardAction,  CardContent,  CardDescription,  CardFooter,  CardHeader,  CardTitle,} from "@oration/canon/components/card";import { InlineStatStrip } from "@oration/canon/components/inline-stat-strip";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function CardParts() {    const [approved, setApproved] = React.useState(false);    return (        <div className="grid w-full items-start gap-4 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">            <Card>                <CardHeader>                    <CardTitle as="h3">Friday wire run</CardTitle>                    <CardDescription>                        212 invoices to 48 suppliers on Oct 2 at 2:00 PM CT.                    </CardDescription>                    <CardAction>                        <Button                            type="button"                            variant="ghost"                            size="sm"                            onClick={() =>                                toast.add({                                    title: "Editing the Friday run",                                    description:                                        "Opens the run in the payments editor.",                                })                            }                        >                            Edit                        </Button>                    </CardAction>                </CardHeader>                <CardContent>                    <InlineStatStrip                        stats={[                            { label: "Total", value: "$1,284,310.00" },                            { label: "Suppliers", value: "48" },                            {                                label: "Held",                                value: "3",                                detail: "missing W-9",                            },                        ]}                    />                </CardContent>                <CardFooter className="justify-end gap-2">                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() =>                            toast.add({ title: "Run exported as CSV" })                        }                    >                        Export CSV                    </Button>                    <Button                        type="button"                        size="sm"                        disabled={approved}                        onClick={() => {                            setApproved(true);                            toast.add({                                type: "success",                                title: "Friday run approved",                                description: "It sends Oct 2 at 2:00 PM CT.",                            });                        }}                    >                        {approved ? "Approved" : "Approve run"}                    </Button>                </CardFooter>            </Card>            <Card size="sm">                <CardHeader>                    <CardTitle as="h3">Card payouts</CardTitle>                    <CardDescription>Sent Sep 25, 41 invoices.</CardDescription>                </CardHeader>                <CardContent>                    <StatusLabel tone="success" className="text-[13px]">                        All 41 remittances delivered                    </StatusLabel>                </CardContent>            </Card>        </div>    );}

States#

Rest
Halcyon Supply14 open invoices
Hover
Halcyon Supply14 open invoices
Focus visible
Halcyon Supply14 open invoices
Loading
import { Card } from "@oration/canon/components/card";import { SkeletonCard } from "@oration/canon/components/skeletons";import { cn } from "@oration/canon/lib/utils";export function States() {    const cells = [        { label: "Rest", className: "" },        { label: "Hover", className: "shadow-border-hover" },        { label: "Focus visible", className: "ring-3 ring-ring/40" },    ];    return (        <div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">            {cells.map((cell) => (                <div key={cell.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {cell.label}                    </span>                    <Card                        className={cn(                            "flex flex-col gap-1 p-4",                            cell.className,                        )}                    >                        <span className="text-13 font-medium text-foreground">                            Halcyon Supply                        </span>                        <span className="text-xs text-muted-foreground">                            14 open invoices                        </span>                    </Card>                </div>            ))}            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">Loading</span>                <SkeletonCard lines={0} />            </div>        </div>    );}
States
StateTreatment
RestCard White on the hairline lift: a 7% ink ring and two soft drops. In dark the ring is a 7% white hairline and the card steps lighter than the plane.
HoverInteractive cards only. The shadow deepens to shadow-border-hover over 150ms (transition-shadow duration-150). Static cards don't react.
Focus visibleInteractive cards draw a 3px Focus Indigo ring at 40% around the whole card when their stretched link or button has keyboard focus.
DraggingKanban cards and reorderable cards take the hover lift while held.
LoadingSkeletonCard from Skeletons, drawn on the same surface so nothing jumps when data arrives.
EmptyKeep the card and its header; put an Empty state inside with one sentence and the next action.

Behavior#

  • Cards don't animate in when a page loads. Only the hover lift transitions, over 150ms on the house ease-out.
  • Add overflow-hidden when a toolbar, table or image runs to the edge, so it clips to the 12px corners.
  • An interactive card has one stretched target: a link or button whose after:absolute after:inset-0 covers the card. Other controls on the card sit above it with relative z-10.
  • In light, Card White equals the White Plane and the card separates only through the lift. In dark, the card steps up in lightness and the ring turns to a faint white hairline.
  • The parts pad themselves with --card-spacing (16px, 12px at size="sm"), so use them on a Card without padding: CardHeader pads its top and sides, CardContent all four, and CardFooter is a flush band.
  • CardHeader becomes a two-column grid when it contains a CardAction, with the action on the title's baseline at the top right.
  • interactive adds the hover lift and the focus ring, both on the card itself and when a stretched [data-overlay] target inside it has keyboard focus.
  • resize tweens the card's width and height when they change, with the Card resize transition from transitions.dev (.t-resize, 300ms on --resize-ease). It needs explicit sizes on both sides of the change, and turns off for reduced motion.

Do and don't#

Bank details

Chase, ending 4417

Verified Sep 12

Do. Split a card's inside with a 70% Well Gray tint well at 10px corners.

Bank details

Chase, ending 4417

Verified Sep 12

Don't. Put a second card with its own lift inside the first. Two edges and two shadows read as clutter.

Orchard Street

Net 30, paid by ACH

Do. Raise the card with shadow-border alone.

Orchard Street

Net 30, paid by ACH

Don't. Add a CSS border and a drop shadow. The border doubles the edge and the heavy shadow lifts it far above its neighbours.
Paid this week
$1.76M
Invoices
339
On time
98.2%
Do. Show a handful of figures as a stat strip inside one card.
$1.76MPaid this week+12%
339Invoices+4%
98.2%On time+0.6%
Don't. Tile big-number cards with tiny labels and trend chips. It is a dashboard cliché and hides what matters.

Content#

  • Titles name the topic in sentence case, one to four words: Payment runs this week, Callbacks due today.
  • The header link names its destination: All payment runs, not View all or See more.
  • No eyebrow or kicker above the title. Dates, counts and owners go in a meta line below it or in the rows.
  • Keep a card's description to one sentence. If it needs more, the topic is too big for one card.
  • Numbers in rows and wells are tabular; amounts keep their cents: $18,420.00.

Accessibility#

  • A card is a plain <div>. When it stands for a section, render a <section aria-labelledby> pointing at its heading, so it appears in the landmarks list.
  • Give the heading the right level for the page outline: h2 for a card on a document page, h3 for a card inside a titled section.
  • Don't wrap a whole card in a <button> or <a> when it contains other controls; nested interactive elements break. Use one stretched link or button and lift the rest with relative z-10.
  • The stretched target's name is the card title, so screen reader users hear Overdue W-9 collection, button, not the whole card's text.
  • The hover lift is decoration. The card must read and work the same without it, and keyboard focus draws its own ring.
Keyboard interactions
KeysAction
TabMoves to the card's stretched link or button, then to any controls on it.
EnterOpens or activates the card.

Design tokens#

Design tokens
TokenUsed for
--cardCard White surface
shadow-borderThe hairline lift (--elevation-border): the card's only edge
shadow-border-hoverHover and drag lift on interactive cards
--radius-xl12px container corners
--mutedTint wells at 70%, the footer band at 50%
--borderHairline dividers and the footer top
--ringFocus ring on interactive cards, 3px at 40%
--card-spacingPadding of the card parts: 16px, or 12px at sm
--resize-dur / --resize-easeThe Card resize transition: 300ms on cubic-bezier(0.22, 1, 0.36, 1)

API reference#

Card

The surface: rounded-xl bg-card shadow-border, no padding. Sets data-slot="card" and data-size. Built on useRender, so render swaps the element while keeping the classes.

Other props spread onto <div>.

Props of Card
PropTypeDefaultDescription
renderReactElementNo defaultThe element to render, such as <section aria-labelledby={id} />, <ul /> or <Link href="…" />. Its props merge with the card's.
interactivebooleanfalseHover lift to shadow-border-hover over 150ms, plus the 3px Focus Indigo ring on the card or a focused [data-overlay] target.
resizebooleanfalseTweens width and height changes with the Card resize transition (.t-resize).
size"default" | "sm""default"--card-spacing for the parts: 16px, or 12px at sm. sm also drops the title to 13px.
classNamestringNo defaultPadding, layout and anything else, merged after the surface.

CardHeader

A grid for the title, description and action, padded on its top and sides. Add border-b to rule it off; it then pads its bottom to match.

Other props spread onto <div>.

No props of its own.

CardTitle

Title: 14px at weight 600. Renders an <h2>.

Other props spread onto <h2>.

Props of CardTitle
PropTypeDefaultDescription
as"h2" | "h3" | "h4""h2"The heading level. Use h3 for a card inside a titled section.

CardDescription

One sentence of 13px Slate Meta under the title.

Other props spread onto <p>.

No props of its own.

CardAction

The header's quiet destination link or action, at 12px on the title's baseline, top right.

Other props spread onto <div>.

No props of its own.

CardContent

The body, padded on all sides by --card-spacing.

Other props spread onto <div>.

No props of its own.

CardFooter

A flex row on a hairline top and a Well Gray band at 50%, flush with the bottom corners.

Other props spread onto <div>.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Five radio option tiles in apps/web (theme, plan, routing, autonomy and agent details) still hand-roll the surface on a <label>. They are form options and belong on ChoiceCard.

Product list rows inside cards use 10px corners (rounded-lg), where DESIGN.md says 8px. These pages follow DESIGN.md.