DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CardCarouselTableDataGridCodeBlock
AI agentsContributing
  1. Components
  2. Card

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/ui

When 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: href for a link, interactive for 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 and layout="split" for a promo with media beside its copy.
  • A pick the card can show. selected paints 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  Card,
  CardMedia,
  CardMediaAction,
  CardMeta,
  CardMetaItem,
  CardRating,
  CardSubtitle,
  CardTitle,
} from "@tessinaui/ui/card";
example.tsx
<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:

example.tsx
<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.

example.tsx
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

PropTypeDefaultDescription
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).
interactivebooleanfalseWhole-card button; ignored when the card contains controls.
hrefstring—Whole-card overlay link; coexists with nested controls. Wins over interactive.
selectedbooleanfalseVisual selection (inset accent ring + wash + data-selected). Pair with a real control for the ARIA state.
disabledbooleanfalseRemoves pointer events; dims interactive/href cards only.
dir"ltr" | "rtl"inheritedMirrors the layout. Stamped only when set.
renderReactElement—Render into a custom element (<article>, <section>, <li>); children are replaced by the card's, refs merge.
transitionNamestring—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

PropTypeDefaultDescription
dividerbooleantrueFull-bleed hairline above the bar.
tone"plain" | "muted""plain"muted paints a strip that bleeds to the card's bottom edge.

CardValue · CardDelta

PropTypeDefaultDescription
CardValue labelstring—Muted caption above the numeral.
CardValue deltaReactNode—Inline chip after the value — typically CardDelta.
CardDelta trend"up" | "down" | "flat""up"Arrow + sr direction word.
CardDelta intent"success" | "error" | "none"from trendOverride when down is good (costs, latency).

CardMedia

PropTypeDefaultDescription
src / altstring— / ""Single image; empty alt marks it decorative.
images / max{src, alt}[] / number— / 3Gallery 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 layout4/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.
insetbooleanfalseBoxed 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 coverLegibility 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>.
transitionNamestring—Names the media frame for a shared-element transition: the hero image that grows into the detail view.

CardMediaBadge · CardMediaAction

PropTypeDefaultDescription
placement"top-start" | "top-end" | "bottom-start" | "bottom-end" | "bottom-center"badge top-start / action top-endLogical corners (+ the bottom-center add spot). Render several badges for multiple corners at once.
CardMediaAction labelstringrequiredAccessible name (icon-only control).
CardMediaAction pressedboolean—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

PropTypeDefaultDescription
placement"top" | "bottom" | "center""bottom"Band of the cover the block occupies.
align"start" | "center" | "end""start"Inline alignment of the stack.

CardPrice

PropTypeDefaultDescription
amount / currency / locale—— / "USD" / hostIntl.NumberFormat when numeric.
originalnumber | string—Struck compare-at price.
discount / discountIntentstring / "success" | "error"— / "success"The third leg of the trio — "25% off", "Save $2" — as a tinted chip.
prefix / periodstring—"From" · "/night".
notestring—Muted sub-line — "incl. taxes", BNPL copy.
prominentbooleanfalseHeadline 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 default variant="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. plain is 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 and CardOverlay blocks 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:

example.tsx
// ✅ 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-p in a boxed stack, 0 everywhere 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.

KeyBehavior (pressable card)
Tab / Shift+TabMoves focus to/from the card (one stop; nested-control cards are not pressable by design).
EnterActivates on key down (native button timing).
SpaceScroll is cancelled on keydown; activates on key up.
EscNo behavior — cards trap nothing.
  • The interactive rule is enforced at runtime; the href overlay link is the legal path for a card-link with nested controls.
  • A pressable card takes its name from CardTitle (aria-labelledby) or your aria-label; missing both warns in development.
  • selected is 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-foreground only on white/plain surfaces, text-foreground/70 on 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.
  • CardRating is role="img" with a composed label; CardPrice ships an sr-only sentence (a <p> cannot carry aria-label); CardDelta reads "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), so dir="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 CardOverlay after the media in the DOM — DOM order is paint order in the cover grid, and it is also the screen-reader reading order.
  • Match overlay to where the text sits. gradient, the cover default, darkens the bottom edge and gradient-top the top edge; text in the middle, or in more than one band, needs scrim, 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-motion and for fit="contain" media.
  • Status belongs to content, not the card surface: compose Badge, Chip or CardMetaItem 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.
Action SheetCarousel

On this page

When to useWhen not to useInstallationUsageExamplesDefaultListing gridHeaderFooterStatChart cardCoverCover with top headerPromo splitInteractive, link and disabledSelectable gridCommerce price grammarPricing plansMedia badgesMedia actionsProgressVariantsSizesRoundedCurving your own contentRTLOn colorLoadingShared-element transitionAPI ReferenceCardCardHeader · CardHeaderContent · CardHeaderActionCardFooterCardValue · CardDeltaCardMediaCardMediaBadge · CardMediaActionCardOverlayCardPriceOther partsThree layouts, one familyWhole-card links vs. whole-card buttonsThe dark platesAnatomy & spacingAccessibilityNotes