The kit's one surface for grouped content: page sections, list pages, form blocks, and stat rows. Purely presentational, it owns spacing, border, and header layout and never holds data state itself.
The canon behind it. docs/design-language/05-cards-and-surfaces.md §A1 (05-cards-and-surfaces.md:15, the universal content-section wrapper, header doubles as documentation), §A2 (05-cards-and-surfaces.md:18, a card-scoped action goes through toolbar, never a sibling div), §A3 (05-cards-and-surfaces.md:21, an already-framed surface uses dividers, not nested cards), and §A4 (05-cards-and-surfaces.md:24, exactly one owner of horizontal inset per framed container).
Toolbar actions. The right-hand button in a card toolbar is never the kit's zero (bare outline plus neutral, no icon, no shade, no tone); it declares itself with an arrow when it navigates and a shade plus a tone when it acts, and it lives in the toolbar slot rather than in a row under the title (canon doc 2 §A10, doc 60 §6, button.md Banned).
When to reach for it#
Any non-table content block on a page wraps in a Card (canon doc 5 §A1: "the universal content-section wrapper"). It is also the list-page shell: a flex-column Card that owns internal scroll while a DataTable fills its body.
Do not reach for it inside an already-framed surface. Peek panels and modals compose their sections with a divider plus a heading, never a nested Card (canon doc 5 §A3: "nesting cards inside a framed overlay is a defect"). Do not reach for it to show a single entity's numeric summary either, that is StatTile, not a Card full of numbers (canon doc 5 §A6).
Contract#
Header (renders only when at least one of these is given):
title/subtitle: string or JSX viarenderNode. The header is documentation as much as framing, canon doc 5 §A1 calls the pairing out explicitly ("Outputs" / "What this run wrote, part by part").Icon(withiconSize,iconProps,iconClassName, the standard icon prop group): the card's mark, left of the title in the same tinted discPageTitlewears, one size down (size-8, 16px;size-7, 14px undercompact). Standard header meta since the round-6 docs home (owner, 2026-08-19, doc 69 D4): a section card carries the icon of the page it leads to, and the sidebar group for that section carries the same one (NavGroup.Icon,app-shell.md), so the index, the column and the page read as one set. The skeleton keeps the disc's space.iconVariant="disc"(default) wears the disc at rest and darkens one rung under the pointer;iconVariant="plain"is bare at rest and gains the disc when the card is hovered, with a transition.iconTone(primary,secondary,accent,info,success,warning,error) colours icon and disc. Every one of those washes is a rung of the tint ladder (canon doc 01 §A8: faint/5, soft/10, strong/15), chosen once incard.tsx'sICON_DISCmap; a page that wants a different shade files feedback against the ladder rather than typing a fresh opacity intoiconWrapClassName(owner, doc 70 E2).toolbar: a free JSX slot, right-aligned. The one place a card-scoped action belongs (canon doc 5 §A2).titleDivider: an opt-in rule under the header extending to the card's edges, for list-style cards.
Body:
children.compact: tighter padding, reserved for denser config/admin screens, not customer-facing content (canon doc 5 §A7).noBorder: drops the border, for flush or nested placements.shadow: one of the sharedSurfaceShadowladder (none | xs | sm | md | lg), defaultsm.
State:
skeleton: renders a card-shaped placeholder that keeps the real card's border, shadow, and body padding, and deliberately does not animate in ("a placeholder should be there, not arrive"). This is the one sanctioned "content is a skeleton, not a spinner" instance for a whole card (canon doc 4 §A1, §A2).noAnimate: opts out of the entrance animation that is otherwise on by default.stagger: cascades the card's own contents in one after another instead of fading the whole card as one block. Present on the type and implemented in source, but has no confirmed shipped call site in either app as of this pass;SkeletonGroup's own, separatestaggerprop is the one actually used everywhere. Do not confuse the two:Card'sstaggeris for a populated card's real content,SkeletonGroup's is for a group of loading placeholders.
Slots and DOM:
- Slot classnames per
CardClassNames:className,titleClassName,subtitleClassName,toolbarClassName,bodyClassName,iconWrapClassName. containerProps: raw DOM attributes for the card root,classNameexcluded (useclassNameabove instead).- The body publishes its own horizontal inset as the
--card-insetCSS variable (1.5rem, or1remundercompact), so a full-bleed child (<Table bleed>) can cancel exactly that padding instead of hardcoding a number. See canon doc 5 §A4, "exactly one owner of horizontal inset per framed container."
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
toolbar={<Chip/> <Chip color="error"/> <Button href .../>} | A card header mixing status chips with one action | speedway/app/routes/admin/team.tsx:373-390 (plan Chip, conditional Suspended Chip, "Open usage console" Button) | Canon doc 5 §A2: "Toolbars may mix status chips with one action" |
className="mt-4 flex max-h-full min-h-0 flex-col" bodyClassName="flex min-h-0 flex-1 flex-col" | The list-page card: grows to the viewport, table scrolls inside it | speedway/app/components/JobsTable.tsx:834-841 | Canon doc 5 §A5 and §B "List page" preset, the dominant list-page shape across both apps |
titleDivider on a card whose body is a stat grid or activity feed | A rule separating header from list-style content, extending to the card edges | walmart-mvp/frontend/src/pages/Admin.tsx:288, 319, walmart-mvp/frontend/src/pages/Home.tsx:93, 115, 118 | The most common walmart pairing; every list-shaped card in Admin and Home carries it |
shadow="sm" stated explicitly even though it is the default | No visual change, but future-proofs the call site if the default ever moves | Every <Card> in walmart-mvp (Admin.tsx, Jobs.tsx:909, Login.tsx:102, Settings.tsx:79, and others) | A real, consistent divergence from speedway, which never restates the default. Match whichever app you are extending |
skeleton standalone, no other content props | A loading placeholder in place of an entire not-yet-loaded card | speedway/app/routes/account/accounts.tsx:82 | The box-preserving loading pattern from canon doc 4 §B |
noBorder={someCondition} | A card whose border is conditional on a caller-owned density flag | speedway/app/components/NewJobForm.tsx:543, 741 | Shows noBorder composing with a boolean the caller derives, not just a static toggle |
Banned combinations#
- A nested
Cardinside aModalor peek panel. Canon doc 5 §A3 names this a defect outright: use aborder-tdivider plus a heading instead. This has already been caught and corrected once (the doc cites it as a measured violation, doc 10 §A10). - Doubling the horizontal inset. A full-bleed child that re-adds its own edge padding instead of canceling the card's (
-mx-6plus first/last-cell padding) overflows by exactly the doubled amount; canon doc 5 §A4 records this happening once ("overflowed 2px") and states the fix as structural: name the single element that owns the inset. compacton customer-facing content cards. Reserved for denser admin and config screens (canon doc 5 §A7); a customer content card that reaches forcompactfor visual tightness rather than density need is working against the convention.- A sibling
<div>holding a card-scoped mutating action instead oftoolbar. Canon doc 5 §A2 is explicit that this always goes through thetoolbarslot. - A hand-typed opacity on the header icon disc (
iconWrapClassName="bg-primary/8",text-base-content/55): the disc's washes are ladder rungs picked byiconVariantandiconTone; a shade that looks wrong is feedback against the ladder (canon doc 01 §A8), not a one-off number. A deliberate one-off override is allowed, named as such in the commit.
Before you adopt this#
Five questions to answer before reaching for Card.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Card is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Not applicable, no local Card clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed Card prop is noted in this doc.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable, Card is a static frame with no control to name.
- Which canon §A rules bind this surface, and which does the composition break? §A2 routes a card action through
toolbar; §A3 bars nesting inside an overlay.
Travels with#
Skeletoninternally, for its ownskeletonmode, not a caller concern.DataTable, in the list-page shape:Cardowns the scroll container and padding, the table cancels the inset on its edge cells (canon doc 5 §A4).StatTile, as a sibling rather than a nesting: aStatTilerow sits inside aCard's body as one of several rows, it does not itself need a wrappingCardper tile.ChipandButton, insidetoolbar, per the sanctioned-combinations row above.
Snippet#
<Card title="Usage" subtitle="This cycle" toolbar={ <span className="flex items-center gap-2"> <Chip size="sm" variant="soft">{planId}</Chip> {suspended && ( <Chip size="sm" variant="soft" color="error">Suspended</Chip> )} <Button size="sm" variant="text" shade color="primary" Icon="Star" content="Open usage console" href={`/admin/teams/${org.id}/usage`} /> </span> }/>speedway/app/routes/admin/team.tsx:370-393