Card
Editorial media card — unboxed listing, cover/poster, promo-split, header/footer, stat, commerce and selection archetypes with overlay badges, save actions, rating, price and kicker slots, in five sizes, four surfaces, two tones, and full RTL.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
Card,
CardMedia,
CardMediaAction,
CardMeta,
CardMetaItem,
CardRating,
CardSubtitle,
CardTitle,
} from "@tessinaui/ui/card";<Card href="/trails/creekside-loop">
<CardMedia src="/pond.jpg" alt="Still forest pond" aspectRatio="4/3">
<CardMediaAction label="Save trail" pressed={saved} onClick={toggle}>
<Bookmark />
</CardMediaAction>
</CardMedia>
<CardMeta>
<CardMetaItem>Easy</CardMetaItem>
<CardMetaItem>
<CardRating value={4.1} count={190} className="px-0" />
</CardMetaItem>
</CardMeta>
<CardTitle>Snake, Vista and Creekside Trail Loop</CardTitle>
<CardSubtitle>Everett Crowley Park</CardSubtitle>
</Card>When to use it
A Card groups one subject — a listing, an article, a metric, a plan — into a scannable, repeatable unit. Reach for it in feeds, grids and dashboards.
When not to: a card is not the default layout tool. Don't wrap arbitrary
page sections in cards, don't nest boxed cards more than one level, and don't
reach for it where a dedicated component owns the pattern — status surfaces are
Banner/Alert, form selection groups are radio/checkbox cards, rich
hover previews are HoverCard, tabular records are Table.
Three layouts, one family
layout="stack"— media block + content column. With the defaultvariant="plain"it is the unboxed feed card (AllTrails, Airbnb): the image is the visible shape, text sits directly on the page background, aligned to the media edge.plainis also this family's ghost/transparent variant for nesting.layout="cover"— content overlaid on a full-bleed image (streaming billboard, story card). Every child shares one grid cell; the media's aspect ratio sizes the card andCardOverlayblocks align to its top, bottom, or center.layout="split"— a text column (CardContent) beside a full-bleed media half (promo card). Stacks back to a column below 360px.
Whole-card links vs. whole-card buttons
Two mechanisms, one rule:
// ✅ Button semantics — no controls inside (the interactive rule)
<Card interactive onClick={open}>…</Card>
// ✅ Link semantics — the overlay-link pattern; nested controls stay legal
<Card href="/listing/42">
<CardMedia src="…">
<CardMediaAction treatment="bare" label="Save" … />
</CardMedia>
…
</Card>interactive enforces the rule at runtime — a card that contains its own
controls demotes itself and warns in development, because role="button"
wrapping buttons is an axe nested-interactive violation. Carbon forbids the
same combination outright; our scan makes it structurally impossible.
href is the sanctioned third path for the ubiquitous "card opens the listing
AND has a wishlist heart" pattern: it renders an invisible anchor beside the
content (labelled by the card title), so nothing nests. Controls stack above
the link at z-30 — built into CardMediaAction, CardActions,
CardHeaderAction and CardFooter; give bare controls relative z-30
themselves.
Either mechanism needs an accessible name: render a CardTitle (auto-wired via
aria-labelledby) or pass aria-label — a pressable card with neither warns
in development.
The dark plates
Cover cards and tone="inverse" paint a plate that is pinned dark in both
themes — a photo doesn't follow the theme and the scrims are black, so the
fallback surface can't flip either. The plate publishes the Surface on-color
contract and wraps children in Surface tone="on-color", which is why the
solid-white "Play" / translucent "My List" pair needs no manual tone props, and
why muted text resolves to a legible white ink instead of --muted-foreground.
Anatomy & spacing
The root owns the size rhythm as documented CSS custom properties — the padding escape hatch:
--card-p— the edge padding of the current size (override with[--card-p:1.5rem]or an inline style to retune the whole card)--card-gap— the rhythm between sections--card-py/--card-px— the resolved paddings: equal to--card-pin a boxed stack,0everywhere else (plain stacks are flush; cover overlays and the split content column pad themselves with the full--card-p)
CardRow, CardHeader and CardFooter apply the inline padding once — parts
inside them skip their own. Bleed a full-width child (a sparkline, a flush
progress rail) through the block padding with -mb-(--card-py).
Every part exposes a data-slot attribute (card, card-media,
card-header, …), and the root stamps data-layout / data-variant /
data-size / data-selected / data-disabled for styling hooks and machine
readability.
Examples
Default
Listing grid
Header
Footer
Stat
Cover
Cover with top header
Promo split
Interactive, link and disabled
Selectable grid
Commerce price grammar
Pricing plans
Media badges
Media actions
Progress
Variants
Sizes
Rounded
Curving your own content
Content you put inside — inset media, a chip, a thumbnail — should curve like the chrome around it, or it reads as pasted on. The card publishes its inner radius as a CSS variable:
<img className="rounded-[var(--card-item-radius)]" />It tracks whatever the component is actually doing: the explicit step when rounded is set (8px at md), and 0 at rounded="none" — so your content squares off exactly when the container does.
RTL
Loading
API Reference
Card
| Prop | Type | Default | Description |
|---|---|---|---|
layout | "stack" | "cover" | "split" | "stack" | Structural mode. |
variant | "plain" | "outlined" | "filled" | "elevated" | "plain" | Surface style; plain is the unboxed feed look and the nesting/ghost variant. |
tone | "default" | "inverse" | "default" | inverse pins a dark plate + on-color contract in both themes. |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Spacing + type rhythm (--card-p / --card-gap). |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "lg" | Corner radius; xl/full cap at the panel scale (24px). |
interactive | boolean | false | Whole-card button; ignored when the card contains controls. |
href | string | — | Whole-card overlay link; coexists with nested controls. Wins over interactive. |
selected | boolean | false | Visual selection (inset accent ring + wash + data-selected). Pair with a real control for the ARIA state. |
disabled | boolean | false | Removes pointer events; dims interactive/href cards only. |
dir | "ltr" | "rtl" | "ltr" | Mirrors the layout. |
render | ReactElement | — | Render into a custom element (<article>, <section>, <li>); children are replaced by the card's, refs merge. |
CardHeader · CardHeaderContent · CardHeaderAction
Header strip: leading glyph (plain children — an Avatar, an icon tile) +
CardHeaderContent (the flexible title/caption column) + CardHeaderAction
(trailing slot pinned to the inline end, z-30; keep it to one control —
kebab, dismiss, chevron, or a Badge). A header-only card is the compact
settings-row archetype.
CardFooter
| Prop | Type | Default | Description |
|---|---|---|---|
divider | boolean | true | Full-bleed hairline above the bar. |
tone | "plain" | "muted" | "plain" | muted paints a strip that bleeds to the card's bottom edge. |
CardValue · CardDelta
| Prop | Type | Default | Description |
|---|---|---|---|
CardValue label | string | — | Muted caption above the numeral. |
CardValue delta | ReactNode | — | Inline chip after the value — typically CardDelta. |
CardDelta trend | "up" | "down" | "flat" | "up" | Arrow + sr direction word. |
CardDelta intent | "success" | "error" | "none" | from trend | Override when down is good (costs, latency). |
CardMedia
| Prop | Type | Default | Description |
|---|---|---|---|
src / alt | string | — / "" | Single image; empty alt marks it decorative. |
images / max | {src, alt}[] / number | — / 3 | Gallery strip with a +N overflow (announced to AT as "N more photos"). |
aspectRatio | "auto" | "1/1" | "5/4" | "4/3" | "3/2" | "video" | "3/4" | "2/3" | "9/16" | per layout | 4/3 stack · 3/4 cover · auto split. |
fit | "cover" | "contain" | "cover" | contain letterboxes logos/screenshots instead of cropping (and never hover-zooms). |
position | "top" | "bottom" | "start" | "end" | "top" | Edge in a stack; half in a split. |
inset | boolean | false | Boxed stacks: float inside the padding with stepped-down radius. |
width | "1/3" | "2/5" | "1/2" | "2/5" | Split-half width. |
overlay | "none" | "scrim" | "gradient" | "gradient-top" | gradient in cover | Legibility layer under overlaid text. |
loading | "lazy" | "eager" | "lazy" | Pass eager + fetchPriority="high" on the LCP hero card. |
fetchPriority | "high" | "low" | "auto" | — | Forwarded to the <img>. |
CardMediaBadge · CardMediaAction
| Prop | Type | Default | Description |
|---|---|---|---|
placement | "top-start" | "top-end" | "bottom-start" | "bottom-end" | "bottom-center" | badge top-start / action top-end | Logical corners (+ the Faire bottom-center add spot). Render several badges for multiple corners at once. |
CardMediaAction label | string | required | Accessible name (icon-only control). |
CardMediaAction pressed | boolean | — | Set for save TOGGLES (aria-pressed); leave unset for one-shot actions (+ Add). |
CardMediaAction treatment | "chip" | "bare" | "tinted" | "chip" | Solid circle · naked glyph · translucent squircle. |
CardOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
placement | "top" | "bottom" | "center" | "bottom" | Band of the cover the block occupies. |
align | "start" | "center" | "end" | "start" | Inline alignment of the stack. |
CardPrice
| Prop | Type | Default | Description |
|---|---|---|---|
amount / currency / locale | — | — / "USD" / host | Intl.NumberFormat when numeric. |
original | number | string | — | Struck compare-at price. |
discount / discountIntent | string / "success" | "error" | — / "success" | The third leg of the trio — "25% off", "Save $2" — as a tinted chip. |
prefix / period | string | — | "From" · "/night". |
note | string | — | Muted sub-line — "incl. taxes", BNPL copy. |
prominent | boolean | false | Headline scale for offer cards. |
Other parts
CardContent (valign), CardRow, CardTitle (lines,
scale: default | display), CardSubtitle (lines), CardDescription
(lines), CardKicker (appearance: text | pill), CardActions
(layout: row | split | stacked, align), CardMeta (separator) /
CardMetaItem (icon, intent: none | error | warning | success | info — the
toned stock/status caption), CardRating (value, max, count,
display: compact | stars | score, scoreLabel — score is the boxed travel
numeral), CardDivider, and CardSkeleton (layout, variant, size,
rounded, aspectRatio, showMedia, lines, label) — one skeleton shape
per layout, silent to screen readers unless you pass label.
Accessibility
The pressable card follows the WAI-ARIA button pattern; the href card is
a plain link with the overlay-link technique.
| Key | Behavior (pressable card) |
|---|---|
Tab / Shift+Tab | Moves focus to/from the card (one stop; nested-control cards are not pressable by design). |
Enter | Activates on key down (native button timing). |
Space | Scroll is cancelled on keydown; activates on key up. |
Esc | No behavior — cards trap nothing. |
- The interactive rule is enforced at runtime; the
hrefoverlay link is the legal path for a card-link with nested controls. - A pressable card takes its name from
CardTitle(aria-labelledby) or youraria-label; missing both warns in development. selectedis visual only — the checked semantics belong on the real control you render (see the selectable-grid example), the Table selection model.- The muted ink is resolved once in context —
--muted-foregroundonly on white/plain surfaces,text-foreground/70on filled and on pressable boxed cards (the hover wash), white inks on the pinned-dark plates. - Overlay text on photography always has a scrim option plus a text-shadow; the fallback plate stays dark in both themes.
CardRatingisrole="img"with a composed label;CardPriceships ansr-onlysentence (a<p>cannot carryaria-label);CardDeltareads "up 8.2%" — the arrow is decorative. The meta interpunct uses CSS alternative text (content: "·" / ""), so VoiceOver skips it.- Media save buttons keep a 44px touch target on phones and stay persistently visible — never hover-revealed.
- Disabled dims only pressable cards (a disabled control is contrast-exempt); a static disabled container keeps full-contrast text.
- All geometry is logical (
start/end), sodir="rtl"mirrors everything, including the rating fill direction. - In collections, render cards as
<li>inside a labelled list (render={<li />}), keep one variant per group, and put one primary action at the bottom with persistent secondary actions top-end.
AT matrix: VoiceOver verified locally per release; NVDA/JAWS untested (no
Windows host) — the reason the badge holds at beta.
Notes
- Place
CardOverlayafter the media in the DOM — DOM order is paint order in the cover grid, and it is also the screen-reader reading order. - Split cards stack below 360px; the media half becomes a top image with the
stacked aspect (default
video). - Interactive/link cards zoom their media slightly on hover (eBay's signature)
— automatically disabled under
prefers-reduced-motionand forfit="contain"media. - Status belongs to content, not the card surface: compose
Badge,ChiporCardMetaItem intent— a whole-card intent tint is deliberately not part of this API (Banner owns intent surfaces). - Full-bleed phone cards:
rounded="none"at the viewport edge is the documented recipe (PolarisroundedAboveequivalent) — apply it responsively from your layout.