Bridge view

Card

A bounded container that groups a coherent unit of content — typically a title, supporting body, optional media, and optional actions — and lifts it as a single perceivable object on the page.

When to use

Use

When grouping a coherent unit of content — title plus body plus optional media plus optional actions — that should perceive as a single object on the page. Typical hosts: dashboards, marketing grids, search results, content collections.

Avoid

For dense list rows where every row is structurally identical and visual separation is purely a horizontal rule — that is `ListItem`. For decorative content tiles in a grid where each tile is primarily an image with a label — `Tile`. For announcing transient information — `Banner` or `Toast`.

Versus related

  • tile

    `Tile` is image-led with minimal supporting text; `Card` is content-led with hierarchical title plus body plus media. A wall of Tiles reads as a gallery; a wall of Cards reads as a feed.

  • list-item

    `ListItem` lives inside an explicit `<ul>` or `<ol>` and cooperates with sibling rows for keyboard navigation and selection semantics. `Card` is a standalone object with no implicit sibling relationship.

  • table

    A wall of `Card` reads as a feed of independent records, each consumed as a standalone unit (a product, a profile); `Table` presents the same records as scannable rows so users compare attributes across many records. Use Table for inventory / contacts / transactions where the cross-row attribute comparison is the primary task; use Card when each record is consumed on its own and the comparison is incidental. The decision test: does the user scan attributes across rows (Table) or consume each record's hierarchy independently (Card)?

Card is a content-led container with hierarchical title, body, optional media, and action affordances — the workhorse of dashboards, content feeds, and grid layouts. Three structural variants (elevated, outlined, flat) cover the emphasis ladder; orientation toggles between vertical and horizontal layouts; the interactive property turns the whole surface into a single activator via the overlay pattern. The reference documents the slot anatomy, the click-handler contract, the variant-vs-state distinction that recurs in Figma drift, and the cross-framework expression of card-as-link.

Highlight
Fig 1.1 · Card · Bridge view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

radix Card
import { Card, Flex, Box, Avatar, Text, Inset } from '@radix-ui/themes';
{/* Basic freeform card — no dedicated slots */}
<Card size="2" variant="surface">
<Flex gap="3" align="center">
<Avatar radius="full" fallback="T" />
<Box>
<Text as="p" weight="bold">Teodros Girmay</Text>
<Text as="p" color="gray">Engineering</Text>
</Box>
</Flex>
</Card>
{/* Card with flush media via Inset */}
<Card size="2" variant="surface">
<Inset clip="padding-box" side="top" pb="current">
<img src="/hero.jpg" alt="Hero image" style={{ display: 'block', width: '100%' }} />
</Inset>
<Text as="p" weight="bold">Card title</Text>
<Text as="p" color="gray">Supporting body text.</Text>
</Card>
{/* Interactive card via asChild */}
<Card asChild size="2" variant="surface">
<a href="/destination">
<Text as="p" weight="bold">Linked card</Text>
</a>
</Card>

Divergence

From Type → To Rationale
anatomy[media] reshaped Inset component (flush wrapper) containing a consumer-supplied img or video element; no dedicated card slot. Radix Themes provides no media slot on Card. Flush-edge imagery is achieved by nesting the Radix `Inset` component (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05) as a child of Card with props `clip="padding-box" side="top" pb="current"`. This delegates all aspect-ratio, lazy-load, and alt-text decisions to the consumer — Radix makes no canonical claim about media handling inside cards.
anatomy[eyebrow] omitted Radix Card has no eyebrow slot or prop. The component is an unstyled container; an eyebrow label is composed by the consumer using Radix `Text` with a colour or size prop. No first-class slot exists. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
anatomy[title] omitted Radix Card has no title slot. Heading text is placed as a free child using Radix `Text` (e.g. `<Text as="p" weight="bold">`). The library does not enforce heading semantics or an accessible-name relationship between the title and the card surface; these are consumer responsibilities. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
anatomy[subtitle] omitted No subtitle slot. Consumer-composed alongside title using Radix `Text` with a muted colour prop. Radix Themes Card is a purely structural container and does not segment content by hierarchy. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
anatomy[body] omitted No body slot. Radix Card accepts arbitrary children; prose, structured content, and nested components are placed directly in the card with no wrapping slot providing spacing or semantic context. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
anatomy[action-group] omitted Radix Card ships no action slot or footer-action region. Action buttons are composed as free children of Card using Radix `Button` or similar primitives. No spacing contract, keyboard-order guarantee, or primary/secondary action grouping is provided by the library. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
anatomy[footer] omitted No footer slot. Metadata or attribution content is placed as free children; the library imposes no visual weight distinction between footer and body content. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
axes.variants[elevated] renamed variant="classic" The canonical `elevated` variant — a card with a shadow that lifts it above the page surface — maps to Radix `variant="classic"`, which applies a box-shadow. Radix does not use the word "elevated". The `classic` name reflects Radix Themes' design vocabulary, not the cross-library concept. (https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/card.props.tsx, fetched 2026-05-05)
axes.variants[outlined] renamed variant="surface" The canonical `outlined` variant — a card demarcated by a visible border — maps to Radix `variant="surface"`, which renders a subtle border and background fill. Radix chose "surface" to align with its colour-token naming system rather than the structural term "outlined". (https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/card.props.tsx, fetched 2026-05-05)
axes.variants[flat] renamed variant="ghost" The canonical `flat` variant — no border and no shadow — maps to Radix `variant="ghost"`, which removes border and background. Radix labels it "ghost" by analogy with ghost buttons; the structural semantics (zero visual boundary) match the canonical `flat` concept. (https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/card.props.tsx, fetched 2026-05-05)
axes.properties[interactive] reshaped asChild prop (polymorphic rendering as <a> or <button>) The canonical `interactive: boolean` enables whole-card activation via an overlay pattern. Radix achieves the same effect through `asChild`, which renders Card as the passed child element (typically `<a href>` or `<button>`), making the entire card surface the interactive activator. There is no `interactive` prop; the affordance is expressed by substituting the root element rather than toggling a flag. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
axes.properties[orientation] omitted Radix Card has no `orientation` prop. Horizontal layout (media on the leading edge) is achieved by the consumer composing Radix `Flex` with `direction="row"` around the card's children. The library provides no canonical layout variant for this. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
axes.properties[density] reshaped size prop ("1" | "2" | "3" | "4" | "5"; default "1") The canonical `density` axis (comfortable / compact) maps imperfectly to Radix `size`, which is a five-step numeric scale controlling the card's padding. Radix's `size` is finer-grained and does not cleanly map to the two-value density enum; it also controls border-radius alongside padding. The canonical two-step density concept is subsumed into a wider design-token scale. (https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/card.props.tsx, fetched 2026-05-05)
axes.properties[density] extended + `size` accepts Responsive<"1" | "2" | "3" | "4" | "5"> — five steps with responsive breakpoint objects (e.g. `size={{ initial: '1', md: '2' }}`), giving per-breakpoint padding and radius control that the canonical `density` enum does not model. Radix Themes exposes responsive prop objects on many of its sizing props. This is a first-class Radix-Themes affordance absent from the canonical axis, which only distinguishes comfortable vs compact. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
axes.states[selected] omitted Radix Card carries no `data-selected` state or `aria-selected` attribute. Selectable card collections are not a Radix Themes primitive concern — the library ships separate `CheckboxCards` and `RadioCards` components for selection semantics rather than toggling a state on the base Card. The canonical `selected` data state has no equivalent on `Card` itself. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
axes.states[loading] omitted Radix Card has no `loading` data state or skeleton affordance. A loading skeleton is consumer-composed (e.g. using Radix Skeleton component inside the card's children). The canonical `loading` data state has no equivalent on the Card primitive itself. (https://www.radix-ui.com/themes/docs/components/card, fetched 2026-05-05)
Why this audit reads the way it does

Radix Themes Card is a minimal styled-container primitive — a `<div>` with theme tokens applied for padding, border-radius, background, and optional shadow. It ships three named variants (surface / classic / ghost) that map cleanly but with renamed terminology to the canonical elevated / outlined / flat axis, and a five-step numeric size scale that subsumes the canonical two-step density. The Card has no internal anatomy: no media, eyebrow, title, subtitle, body, action, or footer slots. Every structural division the canonical anatomy defines must be composed by the consumer from Radix primitives (Text, Flex, Box, Button, Inset). This is a deliberate design choice — Radix Themes is a composable primitive layer, not a pre-structured content component. The canonical `interactive` boolean is realised through the `asChild` prop (polymorphic root element), which makes the whole card surface a single anchor or button without an explicit flag. This means the overlay-pattern complexity described in the canonical mistakes section is the consumer's responsibility to implement correctly. All anatomy divergences are `omitted` (no library slots match canonical slots). Variant divergences are `renamed` (one-to-one terminology map). Property divergences are `reshaped` (asChild for interactive, size for density) plus one `extended` for the responsive size affordance.

Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    Variants for hover / focus / active / disabled

    Code

    CSS pseudo-classes (:hover, :focus-visible, :active) and aria/disabled attributes

    Consequence

    Variant explosion in Figma (3 variants × 4 states × 2 orientations = 24+ component variants), and the developer cannot map a Figma "hover variant" to a CSS pseudo-class without manual translation.

    Correct

    Treat interactive states as a separate spec (a "states" sheet or component property documentation) — not as Figma variants. Variants are reserved for structurally different versions (elevated / outlined / flat).

  2. 02
    Figma

    Media-on-top vs. media-on-leading-edge expressed as separate variants

    Code

    A single component with an `orientation` prop ('vertical' | 'horizontal')

    Consequence

    Designers and developers count card variants differently. Designers see 6+ variants; developers see 3 variants × 2 orientations. Implementation drift: designer adds a third orientation in Figma but the prop type is a binary union.

    Correct

    Model orientation as a *property*, not a variant. Document the property in Figma using a component property of type 'variant' that *only* captures orientation, separate from visual variants.

  3. 03
    Figma

    A clickable card built by stacking a "card" component on top of an invisible "button" component

    Code

    An <a>/<button> wrapping the entire card (or a pseudo-element overlay pattern)

    Consequence

    The Figma artifact does not encode the affordance — designers may not realise the whole card must be a single accessible activator, and developers may forget the keyboard story when implementing.

    Correct

    When the card is interactive as a whole, model the activator as an explicit boolean property (`interactive`) on the canonical component. Render it as a single anchor or button with the rest of the card visually inside, using the overlay pattern to keep nested controls reachable.

  4. 04
    Figma

    Selected state expressed via an "outline + filled background" variant

    Code

    A `data-selected` or `aria-selected` attribute toggled by the application

    Consequence

    The selection style is duplicated in two places (variant + CSS) and inevitably drifts. Worse, treating selection as a variant blocks multi-state selection (e.g., selected + disabled).

    Correct

    Document selection as a *data state*, not a variant. The visual treatment is described once and is composable with the interactive states.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

elevated outlined flat

Properties

The same component, parameterised.

PropertyType
interactive boolean
orientation vertical | horizontal
density comfortable | compact

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
selectedloading
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps elevated / outlined / flat.
OrientationEnumorientationvertical / horizontal — Figma encodes as a separate Variant property, not bundled with the visual variant.
DensityEnumdensity
InteractiveBooleaninteractiveToggles the card-as-link overlay pattern. In Figma it controls hover state visibility; in code it conditionally wraps the card surface with the interactive activator.
Has MediaBooleanmediaSlot-visibility toggle; code conditionally renders the media slot.
MediaSlotmediaSwaps the media component instance (image, video, iframe).
Has EyebrowBooleaneyebrow
EyebrowTexteyebrow
TitleTexttitle
Has SubtitleBooleansubtitle
SubtitleTextsubtitle
BodyTextbodyFor non-prose bodies, Figma uses an Instance Swap on a 'Body Slot' instead.
Has ActionsBooleanactions
Has FooterBooleanfooter
Designer

Figma anatomy

Slot Figma type Hint
media frame Aspect-ratio-locked frame; image fill or instance swap
eyebrow text Text style "Eyebrow" / "Overline"; uppercase or small-caps
title text Heading text style; bound to a component property for content
subtitle text Subtitle text style; lighter weight than title
body text-or-frame Auto-layout text frame, or nested component instance for richer payloads
primary-action from action-group instance Button instance, primary variant, inline-end position
secondary-action from action-group instance Button instance, secondary variant
footer frame Auto-layout horizontal frame; smaller text style
Dev

Code anatomy

Slot Code slot Semantic
media media img-or-video
eyebrow eyebrow text-with-role-or-tag
title title heading-or-link
subtitle subtitle text
body body prose-or-children
primary-action from action-group primary-action button
secondary-action from action-group secondary-action button
footer footer contentinfo-region
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components named slots (<slot name="media">, <slot name="title">, …) attributes reflected to properties (variant="elevated", orientation="horizontal")
React compound components (Card.Media, Card.Title, Card.Body, …) or explicit slot props props with class-variance-authority / tailwind-variants for the variant/property axis
Angular (signals) ng-content with select="[card-media]" / [card-title] etc., and signal-based input() for slots input<'elevated' | 'outlined' | 'flat'>() and input<'vertical' | 'horizontal'>()
Vue named slots (<slot name="media" />, <slot name="title" />, …) defineProps with literal-union types
Both

Events

  1. clickActivateoptional
    Payload
    `{ cardId: string }`. Fires when the user activates the card (click on `interactive: true` variant, Enter on the keyboard-focused activator). For cards wrapping a navigation anchor, the consumer's router handles navigation and this event is informational. Only canonical when `interactive: true`.
    Web Components
    `clickActivate` CustomEvent on the host with `event.detail = { cardId }`.
    React
    `onClick(event)` standard DOM event on the activator; `onActivate(cardId)` when consumers prefer the canonical event shape over the per-element click.
    Angular Signals
    `output<string>('clickActivate')`.
    Vue
    `@click-activate` event with payload `{ cardId }`.
  2. selectedChangeoptional
    Payload
    `{ cardId: string, selected: boolean }`. Fires when the card's `selected` data state flips — typically via a checkbox or whole-card-click on selectable cards or by programmatic change. Only canonical when the consumer is using cards as a selectable collection.
    Web Components
    `selectedChange` CustomEvent with `event.detail = { cardId, selected }`.
    React
    `onSelectedChange(selected: boolean)` per-card callback or aggregated `onSelectionChange(set: Set<string>)` at the grid wrapper.
    Angular Signals
    `output<{ cardId: string, selected: boolean }>('selectedChange')`.
    Vue
    `@update:selected` on the card or `@update:selection` on the grid wrapper.
Both

Performance thresholds

  • cardsPerViewportDesktopvisible-card-count30cards

    Above ~30 cards visible simultaneously at typical desktop widths the surface ceases to read as a feed and becomes a grid; consumers should switch to `Tile` (image-led grid semantics) or paginate. Card optimises for ~10–30 visible at typical content-feed densities.

  • mediaLazyLoadThresholdviewport-distance200px

    Cards with media inside the viewport (and within ~200 px of viewport edges) load eagerly; further cards lazy-load via `loading="lazy"` on the image element or IntersectionObserver-driven loading. Same canonical buffer as Tile to keep the LCP-vs-bandwidth trade-off consistent across image-bearing components.

  • perCardPaintBudgetpaint-time16ms

    Each card aims to paint in under one 60 Hz frame. Cards with elaborate hover/elevation transitions, parallax media, or expensive shadow filters routinely exceed this on long feeds and produce jank during fast scroll. Designers compose the elevated variant within this budget.

Both

Internationalisation

RTL · mirroring

Horizontal-orientation cards flip their leading-edge media to the right side via logical `inline-start` properties. Eyebrow / title / subtitle / body inherit document direction; numerals and dates should follow the locale's preferred numeral system. Action button order reverses: the primary action moves from inline-end (right in LTR) to inline-end (left in RTL) — the *logical* position is unchanged, the visual position mirrors. Decorative media that carries directionality (left-pointing arrow icons, before/after photo pairs) needs explicit alt-text or composition handling.

Text expansion

Title is clamped to 2 lines (`-webkit-line-clamp: 2`) by canonical convention; subtitle to 3; body free-flow. Eyebrow may wrap to a second line under heavy expansion (DE/RU). Action labels follow Button's expansion rules. Multi-column card grids may need to re-flow at narrower widths for languages with longer-than-average text.

Both

Accessibility

Slot Accessibility hint
media Provide alt text for informative images; alt="" for purely decorative media. Do not duplicate the title in alt text.
eyebrow Avoid heading semantics — the eyebrow is metadata, not a heading. If grouped with the title, treat as part of the same labelling relationship via aria-labelledby.
title Use a heading element of the appropriate level for the surrounding document outline. If the card is interactive as a whole, the title also carries the accessible name of the activator.
subtitle If the subtitle qualifies the title's meaning, associate it via aria-describedby on the interactive element.
body Body content keeps its native semantics (lists stay lists, links stay links). Avoid wrapping body in role="text" — it strips semantics from assistive tech.
primary-action Real button with accessible name. First in DOM order; visual position is inline-end via logical properties so RTL mirroring is automatic. If the whole card is also clickable, see mistake "card-as-link-nested-buttons" for the correct overlay pattern.
secondary-action Real button with accessible name. Second in DOM order; visually inline-start of the primary action in LTR.
footer Footer content is not the card's accessible name. If it carries status, use aria-live on the chip itself, not the card.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFor non-interactive cards, focus skips the card surface and lands on nested interactive elements (buttons, links inside actions or the body) in document order. For `interactive: true` cards using the overlay pattern, the title's `<a>` is the single tab stop for the card; nested actions remain individually reachable on subsequent Tab presses (kept on a higher stacking context).
Enter (on overlay link)Activates the card-as-link target. Spacebar does not activate — anchors are Enter-only by native convention.

Screen-reader announcements

TriggerExpected
Focus enters card-as-linkThe title's accessible name followed by "link" (e.g. "Q3 revenue report, link"). The eyebrow, subtitle, and body are not part of the link's accessible name unless explicitly tied via `aria-labelledby`.
`selected` data state setSelection is announced via `aria-selected="true"` on the card's primary actionable element. SR reads "selected" before the accessible name (varies slightly by SR).

axe-core rules to assert

  • link-name
  • color-contrast
  • heading-order
  • image-alt

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/card/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. HTML specHTML interactive element semantics + WAI-ARIA Authoring Practices

    When the whole card is interactive, the activator is a single real `<a href>` (navigation) or `<button>` (in-page action) — never a `<div onclick>` and never `role="button"` on the card root. Nested actions remain reachable via the pseudo-element overlay pattern (the title's anchor extends a `::before` pseudo over the card, nested buttons sit on a higher stacking context).

    Wrapping the entire card in `<a>` traps nested buttons — keyboard users cannot activate inner controls, and SR reads the nested buttons as part of the link's accessible name. `<div onclick>` makes the card unfocusable and unannounceable. The overlay pattern is the only construction that keeps both the whole-card-clickable affordance and the nested-actions contract.

  2. Canon

    Card and Tile and ListItem are not interchangeable. Card is content-led (hierarchical title + body + media + actions); Tile is image-led (visual content primary, text supplementary or absent); ListItem is a horizontal list row with leading icon + primary + secondary + trailing affordances. The decision test is "what is the user scanning for?" — text hierarchy, visual content, or row-of-rows metadata.

    Mixing the three contracts produces composite components that read incoherently — text-heavy "tiles" lose the gallery-scan affordance; image-led "cards" force designers to invent media-dominant variants the canon does not endorse; row-style cards collapse the list/grid distinction that drives layout choice.

  3. Canon

    Interactive states (hover / focus-visible / active / disabled) are NOT modelled as Figma variants. Variants are reserved for structurally different versions (`elevated` / `outlined` / `flat`); states are documented once and rendered via CSS pseudo-classes and `aria-disabled` / `aria-selected`.

    Modelling states as variants explodes the matrix (3 variants × 4 interactive states × 2 orientations × 2 densities = 48+ Figma variants) and forces designers and developers to translate the variant matrix into pseudo-classes by hand on every implementation. The anatomy splits intentionally to keep both surfaces small.

Vocabulary drift

Material 3
Card
Material 3 picks `elevated` and `outlined` as canonical variants matching this canon; `flat` does not appear in Material's roster but ships in Polaris and Atlassian as a low-emphasis variant.
Polaris
Card
Polaris ships a Card primitive with built-in section slots (`Card.Section`) — finer-grained than the canonical anatomy's body slot. The composite contract matches; the slot granularity differs.
Carbon
Tile (selectable / clickable)
Carbon labels its content-led card primitive "Tile", producing terminology collision with this canon's image- led `Tile`. Implementation audits document the per-library naming via `componentName` and the contract-vs-naming distinction matters because Carbon-Tile ≈ canonical-Card, not canonical-Tile.
Atlassian
Card
Atlassian's Card matches the canonical content-led contract; the elevated / outlined / flat axis maps to Atlassian's `appearance` prop.
Both

Common mistakes

Blocker

#clickable-card-no-keyboard

Click handler on the card without a focusable activator

Problem

Attaching a click handler to a <div> card with no <a> or <button> inside makes the card unreachable by keyboard and unannounceable to assistive tech.

Fix

Always anchor the activation on a real interactive element (<a href> for navigation, <button> for in-page actions). If the visual treatment requires the whole card to look clickable, use the overlay pattern — never role="button" on the card div.

Minor

#variant-explosion-from-states

Modelling interactive states as Figma variants

Problem

Adding hover / focus / active / disabled as variants in Figma causes the variant matrix to explode and forces the developer to hand-translate Figma variants into CSS pseudo-classes.

Fix

Document interactive states once in a separate "states" sheet or component documentation. Reserve Figma variants for structurally different versions (elevated / outlined / flat). Document the same separation in the canonical reference and the production library.

Minor

#media-alt-duplicates-title

Image alt text repeats the card title

Problem

Setting `alt` to the title text causes screen readers to announce the title twice when the card is interactive — once as the link name and once as the image alt.

Fix

For decorative / supporting media use `alt=""`. For media that carries information not in the title, write alt that adds the missing information ("Chart showing 12% YoY growth"), not a restatement of the headline.