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.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
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.
Figma↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 01 Figma
Variants for hover / focus / active / disabled
CodeCSS pseudo-classes (:hover, :focus-visible, :active) and aria/disabled attributes
ConsequenceVariant 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.
CorrectTreat 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).
- 02 Figma
Media-on-top vs. media-on-leading-edge expressed as separate variants
CodeA single component with an `orientation` prop ('vertical' | 'horizontal')
ConsequenceDesigners 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.
CorrectModel 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.
- 03 Figma
A clickable card built by stacking a "card" component on top of an invisible "button" component
CodeAn <a>/<button> wrapping the entire card (or a pseudo-element overlay pattern)
ConsequenceThe 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.
CorrectWhen 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.
- 04 Figma
Selected state expressed via an "outline + filled background" variant
CodeA `data-selected` or `aria-selected` attribute toggled by the application
ConsequenceThe 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).
CorrectDocument selection as a *data state*, not a variant. The visual treatment is described once and is composable with the interactive states.
Variants, properties, states
Variants
Structurally different versions of the component.
elevated outlined flat Properties
The same component, parameterised.
| Property | Type |
|---|---|
interactive | boolean |
orientation | vertical | horizontal |
density | comfortable | compact |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | selectedloading |
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps elevated / outlined / flat. |
Orientation | Enum | orientation | vertical / horizontal — Figma encodes as a separate Variant property, not bundled with the visual variant. |
Density | Enum | density | — |
Interactive | Boolean | interactive | Toggles 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 Media | Boolean | media | Slot-visibility toggle; code conditionally renders the media slot. |
Media | Slot | media | Swaps the media component instance (image, video, iframe). |
Has Eyebrow | Boolean | eyebrow | — |
Eyebrow | Text | eyebrow | — |
Title | Text | title | — |
Has Subtitle | Boolean | subtitle | — |
Subtitle | Text | subtitle | — |
Body | Text | body | For non-prose bodies, Figma uses an Instance Swap on a 'Body Slot' instead. |
Has Actions | Boolean | actions | — |
Has Footer | Boolean | footer | — |
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 |
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 |
Cross-framework expression
| Framework | Structure mechanism | Variant 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 |
Events
clickActivateoptionalselectedChangeoptional
Performance thresholds
cardsPerViewportDesktopvisible-card-count≥30cardsAbove ~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-distance≥200pxCards 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-time≥16msEach 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.
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.
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. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | For 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
| Trigger | Expected |
|---|---|
| Focus enters card-as-link | The 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 set | Selection 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-namecolor-contrastheading-orderimage-alt
Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe:
/api/components/card/a11y-fixture.json
Contracts
Non-negotiable contracts
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.
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.
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.
Common mistakes
#card-as-link-nested-buttons
Card-as-link with nested action buttons
Wrapping the whole card in an <a> turns the entire surface into a single tab stop and traps nested buttons — keyboard users cannot activate the inner controls, and screen readers announce the nested buttons as part of the link's name.
Use the pseudo-element overlay pattern: keep the card as a regular container, give the title a real <a> with `::before` covering the whole card via absolute positioning, and let nested actions sit on a higher stacking context (z-index) so clicks on them aren't intercepted. The link receives the card's accessible name from the title.
#clickable-card-no-keyboard
Click handler on the card without a focusable activator
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.
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.
#variant-explosion-from-states
Modelling interactive states as Figma variants
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.
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.
#media-alt-duplicates-title
Image alt text repeats the card title
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.
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.