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.
pnpm add @tessinaui/uiWhen to use
- One subject, grouped. A listing, article, plan or metric reads as one scannable, repeatable unit in a feed, grid or dashboard.
- Browsing and comparing. Give every card in a set the same anatomy and one
variant, so the eye compares like with like. - A summary that leads somewhere. Make the whole card the way in:
hreffor a link,interactivefor a button. Keep to one primary action and put any other action in a real control. - Media-led content. Use
layout="cover"for an image with text over it andlayout="split"for a promo with media beside its copy. - A pick the card can show.
selectedpaints the chosen state; pair it with a real control for the checked semantics.
When not to use
- To lay out a page. A card is not the default container. Don't wrap every page section in one or nest boxed cards more than one level; use Surface or plain layout.
- For a status or message. Banners and alerts own intent surfaces, so use Banner or Alert rather than tinting a card.
- For a choice in a form. Use the card styles of Checkbox or Radio, which carry the control and its semantics.
- For a rich preview on hover. Use HoverCard.
- For records compared by column. Use Table or DataGrid when users sort, filter or scan values side by side.
- For a whole-card button that holds its own controls. A card with a nested control stops being pressable; give it
href, or move the actions out.
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>Examples
Default
Listing grid
Header
Footer
Stat
Chart card
A full card holding enclosed tabs and a chart. The shell softens to a 24px corner and the enclosed tabs cap at a folder-tab corner, so neither reads as a stadium.
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
Pass dir="rtl" to mirror the layout. Leave it unset and the card inherits the page's direction; a render host keeps its own dir.
On color
Inside a coloured Surface, a plain card takes the plate's inks and a pressable one rings in the plate's ink. outlined, filled and elevated paint a page panel and hand their content the default tone. The dark plates work the other way round: they publish on-color to what they hold.
Loading
Shared-element transition
Give a list card and the view it opens the same transitionName, and give their
media frames a second shared one. Then run the state change through
startViewTransition from @tessinaui/ui/view-transition, and the browser morphs
one into the other. Pass it the state flip only. The page freezes between the two
snapshots, so a fetch belongs before the call. Names must be unique on the page
(trail-${id}, never a constant); with a duplicate the browser skips the transition.
Where View Transitions are missing, or under prefers-reduced-motion, the swap is
instant, so the instant path is a whole behaviour of its own. The swap unmounts the
focused card, so move focus with the view: to the detail's first control on open,
and back to the card on close.
import { startViewTransition } from "@tessinaui/ui/view-transition";
<Card interactive transitionName={`trail-${id}`} onClick={() => startViewTransition(() => setOpen(id))}>
<CardMedia transitionName={`trail-media-${id}`} />
…
</Card>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. filled is a card for a grouped page (bg-muted in light, bg-background in dark). |
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" | inherited | Mirrors the layout. Stamped only when set. |
render | ReactElement | — | Render into a custom element (<article>, <section>, <li>); children are replaced by the card's, refs merge. |
transitionName | string | — | view-transition-name for a shared-element transition. Unique on the page; a caller's style wins. |
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>. |
transitionName | string | — | Names the media frame for a shared-element transition: the hero image that grows into the detail view. |
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 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.
Three layouts, one family
layout="stack"— media block + content column. With the defaultvariant="plain"it is the unboxed feed card of travel and listing feeds: 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. The scan makes the
combination structurally impossible rather than leaving it to convention.
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.
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 sits on a darkening layer that clears AA over a pure-white photo, 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 + Safari passed 2026-09-14 — "Whole-card button, button", "link, Whole-card link", "Save trail, toggle button", "heading level 3, Whole-card link", and the disabled card as "Disabled, dimmed button". NVDA/JAWS untested (no Windows host).
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. - Match
overlayto where the text sits.gradient, the cover default, darkens the bottom edge andgradient-topthe top edge; text in the middle, or in more than one band, needsscrim, which darkens the whole frame. - 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 — 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 (the "rounded above a breakpoint" shape) — apply it responsively from your layout.