DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 default variant="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. 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:

// ✅ 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-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.

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

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.
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""ltr"Mirrors the layout.
renderReactElement—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

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

CardMediaBadge · CardMediaAction

PropTypeDefaultDescription
placement"top-start" | "top-end" | "bottom-start" | "bottom-end" | "bottom-center"badge top-start / action top-endLogical corners (+ the Faire 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.

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 always has a scrim option 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 verified locally per release; NVDA/JAWS untested (no Windows host) — the reason the badge holds at beta.

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.
  • 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-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 (Polaris roundedAbove equivalent) — apply it responsively from your layout.
CalendarCarousel

On this page

PlaygroundInstallationUsageWhen to use itThree layouts, one familyWhole-card links vs. whole-card buttonsThe dark platesAnatomy & spacingExamplesDefaultListing gridHeaderFooterStatCoverCover with top headerPromo splitInteractive, link and disabledSelectable gridCommerce price grammarPricing plansMedia badgesMedia actionsProgressVariantsSizesRoundedCurving your own contentRTLLoadingAPI ReferenceCardCardHeader · CardHeaderContent · CardHeaderActionCardFooterCardValue · CardDeltaCardMediaCardMediaBadge · CardMediaActionCardOverlayCardPriceOther partsAccessibilityNotes