Card
The raised container: 12px corners, a hairline lift and 16px of padding.
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-hiddenso 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
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
rounded-[10px] bg-muted/70), never a second bordered or shadowed card.No nested cards
No hero-metric grids
Anatomy#
Remittance
EditSent to Northwind Freight with each payment.
- Surface.
rounded-xl bg-card shadow-border: Card White, 12px corners and the hairline lift. No CSS border. - 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). - Content. 16px padding and 13px copy. Lists inset 8px (
px-2 pb-2) with 8px-corner rows that fill Well Gray on hover. - Tint well. A sub-region at
rounded-[10px] bg-muted/70, 12px by 8 to 10px of padding, for grouped figures or a note. - 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 suppliersimport { 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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Card 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. |
| Hover | Interactive cards only. The shadow deepens to shadow-border-hover over 150ms (transition-shadow duration-150). Static cards don't react. |
| Focus visible | Interactive cards draw a 3px Focus Indigo ring at 40% around the whole card when their stretched link or button has keyboard focus. |
| Dragging | Kanban cards and reorderable cards take the hover lift while held. |
| Loading | SkeletonCard from Skeletons, drawn on the same surface so nothing jumps when data arrives. |
| Empty | Keep 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-hiddenwhen 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-0covers the card. Other controls on the card sit above it withrelative 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 atsize="sm"), so use them on aCardwithout padding:CardHeaderpads its top and sides,CardContentall four, andCardFooteris a flush band. CardHeaderbecomes a two-column grid when it contains aCardAction, with the action on the title's baseline at the top right.interactiveadds the hover lift and the focus ring, both on the card itself and when a stretched[data-overlay]target inside it has keyboard focus.resizetweens 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
Bank details
Chase, ending 4417
Verified Sep 12
Orchard Street
Net 30, paid by ACH
shadow-border alone.Orchard Street
Net 30, paid by ACH
- Paid this week
- $1.76M
- Invoices
- 339
- On time
- 98.2%
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:
h2for a card on a document page,h3for 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 withrelative 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.
| Keys | Action |
|---|---|
| Tab | Moves to the card's stretched link or button, then to any controls on it. |
| Enter | Opens or activates the card. |
Design tokens#
| Token | Used for |
|---|---|
--card | Card White surface |
shadow-border | The hairline lift (--elevation-border): the card's only edge |
shadow-border-hover | Hover and drag lift on interactive cards |
--radius-xl | 12px container corners |
--muted | Tint wells at 70%, the footer band at 50% |
--border | Hairline dividers and the footer top |
--ring | Focus ring on interactive cards, 3px at 40% |
--card-spacing | Padding of the card parts: 16px, or 12px at sm |
--resize-dur / --resize-ease | The 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | No default | The element to render, such as <section aria-labelledby={id} />, <ul /> or <Link href="…" />. Its props merge with the card's. |
interactive | boolean | false | Hover lift to shadow-border-hover over 150ms, plus the 3px Focus Indigo ring on the card or a focused [data-overlay] target. |
resize | boolean | false | Tweens 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. |
className | string | No default | Padding, 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>.
| Prop | Type | Default | Description |
|---|---|---|---|
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.
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.