Skip to content

Card

A content surface that groups related content and actions into one unit.

Stablev0.4.2added in v0.1.0@garn/ui/card
On this page

Default

Header + content + footer actions — the default composition.

Styling

Appearance

The two surface families side by side: white surfaces defined by an edge or a shadow, filled surfaces defined by a tint, plus the frosted glass tile.

Tone

The six tones on the default appearance — a white card takes its status colour on the edge.

Layout

Media

CardMedia bled to the rim with a concentric radius.

Horizontal

A row layout with media beside content, collapsing to a column on small screens.

Divided

Hairline rules between the header, content, and footer regions.

Action

A trailing control in the header's top-right cell.

Meta

CardMeta — a leading avatar beside an eyebrow, title, and subtitle.

Interaction

Interactive

The whole surface promoted to a link with asChild, plus the interactive affordance.

Expandable

An above/below-fold reveal composing Collapsible inside the card.

Settings

A settings row: the title labels the control and the description is linked with aria-describedby, so the whole row toggles.

Mounting

Kpi

A KPI tile — a Stat figure with a delta Badge.

Kpi Bleed

The KPI tile with a Sparkline bled edge-to-edge as its footer.

Data Table

A padded header above a Table bled to the rim, with its caption intact.

Bar List

A ranked BarList with an avatar and tag meta row.

Empty

Empty mounted bare rather than inside CardContent — proof of the don't-double-pad rule.

Loading

CardSkeleton holding the card's shape while data loads.

Composed

Billing

A full billing panel composing header, divided regions, and footer actions.

Note

A toned note card — the tone rides the surface the appearance chose.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add card
First time? Set up garn in your project
Registry
card
Deps
@radix-ui/react-slotclass-variance-authority
Registry deps
utilsframeinsetaspect-ratioskeleton

Import

import { Card, CardMedia, CardBleed, CardMeta, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter, CardSkeleton } from "@garn/ui/card";

Anatomy

The parts this component is built from — compose them to assemble it.

CardRequired[data-slot="card"]

The surface. Padding-free by design — the parts carry the padding, so a direct child is already edge-to-edge.

CardMedia[data-slot="card-media"]

An image/video region clipped to the card radius. Bleeds to the rim as a direct child of Card; inheritPadding insets it instead. Optional ratio frames it with AspectRatio.

CardBleed[data-slot="card-bleed"]

Mount any content edge-to-edge (a Table, List rows, a chart footer). Place it as a DIRECT child of Card; inheritPadding re-adds the horizontal inset while the region stays full-width.

CardMeta[data-slot="card-meta"]

The identity cluster: a leading avatar/media beside an eyebrow, title, and subtitle. The text column truncates rather than pushing the media.

CardHeader[data-slot="card-header"]

Top region; stacks title + description with the field gap and surface padding. Switches to a two-column grid when it contains a CardAction.

CardTitle[data-slot="card-title"]

The card's heading text. headingLevel renders a real <h2>–<h4>; asChild takes full control of the element.

CardDescription[data-slot="card-description"]

Muted supporting text under the title.

CardAction[data-slot="card-action"]

A trailing control in the header's top-right cell — a menu trigger, a close button, a status Badge.

CardContent[data-slot="card-content"]

The main body region (surface padding, no top padding).

CardFooter[data-slot="card-footer"]

Bottom region for actions (surface padding, no top padding).

CardSkeleton[data-slot="card-skeleton"]

A loading placeholder shaped like a card — optional media box or leading avatar, a title line, and lines body lines. Decorative (aria-hidden).

Props

The component's public props and their types.

appearance
Description
No description.
Type
"elevated" | "framed" | "ghost" | "glass" | "outline" | "soft"
Default
outline
interactive
Description
No description.
Type
boolean
Default
false
tone
Description
No description.
Type
"brand" | "danger" | "info" | "neutral" | "success" | "warning"
Default
neutral
asChild
Description
Render the single child element AS the card root (an `<a>`/`<button>` for a whole-surface control, or a semantic `<article>`). Pair with `interactive` for the affordance. A promoted `<button>` must not wrap block/interactive content — use `interactive` + a stretched `CardTitle` link for rich nav cards.
Type
boolean
Default
false
collapseBelow
Description
Breakpoint below which a `horizontal` card collapses to vertical; `false` never collapses.
Type
"lg" | "md" | "sm" | false
Default
"sm"
disabled
Description
For an `interactive` card: mark it disabled — `aria-disabled`, no pointer events, and click / Enter / Space are blocked. Best with the whole-surface (`asChild`) or stretched-link pattern (a single control). A disabled card blocks activation but does not remove *nested* focusable controls from the tab order — if the card hosts its own, disable those too.
Type
boolean
Default
false
divided
Description
Hairline rules between the header / content / footer regions — for dense info cards. Reflects `data-divided`.
Type
boolean
elevation
Description
Legacy surface alias, superseded by `appearance`.
Type
"hairline" | "raised"
orientation
Description
Layout flow. `vertical` stacks the parts; `horizontal` lays them out in a row (media beside content), collapsing to vertical below `collapseBelow`.
Type
"horizontal" | "vertical"
Default
"vertical"

Plus 280 inherited native <div> attributes.

CardMedia

inheritPadding
Description
Inset the media from the card edge instead of bleeding edge-to-edge: the media slot becomes a padded host and the image sits in an inner container whose corner radius is **concentric** — the card's radius minus the gap — so its curve runs parallel to the card's (the garn nesting rule, see `lib/inset`). Tune the gap with the `--inset-gap` custom property (default 6px here). Padding, not margin; a real inner container, not a pseudo-element.
Type
boolean
Default
false
ratio
Description
Constrain the media to a width:height ratio (composes AspectRatio).
Type
number

Plus 280 inherited native HTML attributes.

CardBleed

inheritPadding
Description
Re-add the surface's horizontal padding so the content insets while the region still spans edge-to-edge — e.g. a full-width rule under text that lines up with the card's other padded regions.
Type
boolean
Default
false

Plus 280 inherited native HTML attributes.

CardMeta

description
Description
The subtitle / supporting line under the title.
Type
React.ReactNode
eyebrow
Description
A small overline / kicker above the title.
Type
React.ReactNode
headingLevel
Description
Render the title as a real heading (`<h2>`–`<h4>`) — forwarded to CardTitle.
Type
2 | 3 | 4
media
Description
Leading media — an Avatar, an icon tile, or a thumbnail.
Type
React.ReactNode
title
Description
The primary title line (rendered via `CardTitle`).
Type
React.ReactNode

Plus 279 inherited native HTML attributes.

CardTitle

asChild
Description
Render the child element as the title — use it for a real heading (`<h2>`–`<h4>`, WCAG 1.3.1) or the anchor of a stretched-link nav card.
Type
boolean
Default
false
headingLevel
Description
Render the title as a real heading at this level (`<h2>`–`<h4>`) so sibling cards keep a flat, valid outline — the ergonomic path to a semantic heading without `asChild`. An explicit `asChild` wins when both are set.
Type
2 | 3 | 4

Plus 280 inherited native HTML attributes.

CardSkeleton

avatar
Description
Reserve a leading avatar circle in the header instead of a plain title line.
Type
boolean
Default
false
lines
Description
Number of body placeholder lines (the last is shortened).
Type
number
Default
3
media
Description
Reserve a leading full-bleed media box (16:9).
Type
boolean
Default
false

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="card"]

States

SelectorState
[data-slot=card]:hoverhover
[data-slot=card]:activeactive
[data-slot=card]:focus-withinfocus-visible
[data-slot=card][data-disabled]disabled
loading
empty
PropertyTokenTier
padding--garn-pad-surfacesemantic · air
headerGap--garn-gap-fieldsemantic · air
frameStrip--garn-space-3primitive
frameStripAlpha--garn-frame-strip-alphabrandablesemantic
transitionDuration--garn-motion-basesemantic

When to use

Group related content and actions into one surface — a summary, a form section, a dashboard tile, a nav tile, or a host for a chart, table, or list.

Reach for something else when

  • A status message the user must read (use alert — a toned Card is a surface, not an announcement)
  • a modal task (use dialog)
  • a floating surface anchored to a trigger (use popover)
  • a whole empty state (use empty on its own, not boxed in a card)
  • a page section that needs no contained surface (use plain layout).

Overview

Card groups related content and actions into one surface — a summary, a form section, a dashboard tile, a host for a chart or table. The compound parts give it a predictable rhythm: CardHeader (with CardTitle and CardDescription), CardContent for the body, CardFooter for actions, and CardBleed for anything that should run to the rim. The root itself is padding-free, so the parts own the spacing and a direct child is already edge-to-edge.

It is a container, not an interaction. interactive supplies the affordance — a quiet state layer, a lift, and one focus ring — but the real target comes from asChild (promoting the root to a link or button) or from stretching a link out of CardTitle.

Guidelines

Two axes, and they compose. appearance decides which slots a card has — a fill? a border? a shadow? — and tone hues the slots that exist. That means a tone never overrides the surface family you picked: on soft and framed it tints the fill, on outline and elevated it colours the edge and leaves the white surface alone, and on ghost it has only text to reach. A white card with a danger hairline is a different, quieter thing than a fully tinted danger panel, and both are one prop pair away.

Text follows the surface, not the tone. A tinted fill takes its paired foreground so the contrast holds; a white fill keeps the neutral text colour. This is deliberate — it keeps a toned card from washing a hue over a Table or Chart mounted inside it, which isn't sitting on the tint.

Reach for a card to group, not to decorate. A surface earns its keep when it sets genuinely related content apart; boxing every element makes a layout noisy. Don't nest cards, and don't box content that already brings its own surface — Empty, Field appearance="card", and List appearance="card" mount bare.

Mount content in the right region. Padding-free content (a Stat, Chart, Sparkline, DataList, or form group) belongs in CardContent. Content that wants the full width — a Table, List rows, a sparkline footer, media — belongs in CardBleed or CardMedia as a direct child of the card. Whatever you mount keeps its own accessibility wiring: a visual card heading does not name a Table or a RadioGroup.

Make whole-card affordances real. If the entire card is clickable, it should be one link or button with a single focus ring and one accessible name — not a surface with scattered nested targets fighting the outer one.

Best practices

Do
  • Compose with CardHeader / CardTitle / CardDescription / CardContent / CardFooter so spacing stays consistent.
  • Pick the appearance for the surface you want, then add a tone — the two axes compose.
  • Mount edge-to-edge content (a Table, List rows, a chart footer) with CardBleed as a direct child of Card.
  • Give CardTitle a headingLevel so the page outline stays valid.
  • Put the primary action last in CardFooter, and a single trailing control in CardAction.
Don't
  • Don't hand-pad the card — the parts carry --garn-pad-surface.
  • Don't nest a card inside a card, or box content that already brings its own surface (Empty, Field appearance="card", List appearance="card").
  • Don't set divided on a card whose child already owns its dividers (DataList, List, Timeline).
  • Don't put a click handler on the surface — promote it with asChild or stretch a link from the title.
  • Don't reach for a toned card to deliver a status message; that's Alert's job.

Content guidelines

  • Give the card a clear title and lead the body with the key fact.
  • Label the card's action by outcome, and keep to one primary action per card.
  • Keep one card to one idea — if it grows several unrelated sections and footers, split it.

Troubleshooting

tone="danger" on the default card only colours the border, not the background.

Cause. That's the contract: appearance decides which slots exist and tone hues them. The default outline is a white surface, so the tone rides its edge.

Fix. Use appearance="soft" (tinted fill) or appearance="framed" (tinted fill + tone border) when you want a tinted panel. For a status message, use Alert instead of a Card.

Content mounted in a card has two layers of padding, or a card appears inside a card.

Cause. Empty, Field appearance="card", and List appearance="card" bring their own surface, so wrapping them in CardContent doubles it.

Fix. Mount them bare as a direct child of Card and switch the child to its flat appearance (plain), never a card inside a card.

CardBleed doesn't reach the card's edges.

Cause. It's nested inside a padded region. The Card root is padding-free, so only a DIRECT child is already flush — there are no negative margins to escape a CardContent.

Fix. Move the CardBleed up to be a direct child of Card. Use inheritPadding if you want the content inset while the region stays full-width.

A card shows double dividers between its sections.

Cause. divided was set on the Card while a child (DataList, List, Timeline) already draws its own rules.

Fix. Let the child own its dividers and drop divided from the Card.

appearance="glass" looks like a flat translucent panel with no frost.

Cause. backdrop-blur needs something behind it — over a solid surface there is nothing to blur.

Fix. Place the glass card over imagery or colour, or pick a different appearance for in-flow surfaces.

A disabled interactive card still lets the keyboard reach the buttons inside it.

Cause. disabled blocks activation of the card itself (aria-disabled plus a capture-phase guard); it does not remove nested focusable controls from the tab order.

Fix. Disable the nested controls too, or use the whole-surface (asChild) pattern where the card is the only control.

The console warns that elevation is deprecated.

Cause. elevation is a one-release alias kept for the old API.

Fix. Use appearanceraisedelevated, hairlineoutline.

Accessibility

Role
none
Focus
An interactive card takes a single :focus-within ring, so focusing any control inside it rings the whole surface once.

Accessibility requirements

warn

Render CardTitle as a real heading via `headingLevel` (or `asChild`) at the level that keeps the page outline valid; sibling cards share one flat level.

when a card has a title

error

The card exposes exactly one focus ring via :focus-within — focusing an inner title-link rings the whole card rather than adding a second ring.

when the `interactive` prop is set

error

The hover/press overlay is pointer-events:none, so a click on a nested action inside the card still reaches that action.

when interactive is set

error

Stretch one descriptive link (CardTitle asChild + an inset overlay) so assistive tech announces a single named link; keep independently clickable controls above the overlay.

when the whole surface is a link but only part of it is the anchor

error

The promoted element must not wrap other interactive or block content — use `interactive` plus a stretched CardTitle link for rich nav cards instead.

when asChild promotes the root to a <button> or <a>

error

The card sets aria-disabled and blocks click / Enter / Space. It does not remove nested focusable controls from the tab order — disable those too.

when interactive and disabled are both set

warn

CardSkeleton is aria-hidden; the region it stands in should carry aria-busy so the wait is announced.

when CardSkeleton is rendered

error

Mounted content keeps its own accessible name — a Table needs its caption, a RadioGroup an explicit group label, a List/Timeline/TagGroup a name. A visual card heading does not associate with them.

when a Table, RadioGroup, List, Timeline, or TagGroup is mounted in a card

warn

Use `appearance` instead — `raised` → `elevated`, `hairline` → `outline`. `elevation` is ignored when `appearance` is set.

when the deprecated `elevation` prop is passed

Keyboard

TabMoves to the card when it is a real control (asChild link/button) or to the controls inside it. A plain Card is not a tab stop.
EnterActivates a card promoted to a link or button via asChild. Blocked while disabled.
SpaceActivates a card promoted to a button via asChild. Blocked while disabled.
A1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.4Link Purpose (In Context)
  • Card is a styled <div> with no implicit role — carry the meaning with a real heading (CardTitle headingLevel) and the page's landmarks.
  • If the whole card is a target, promote it with asChild to a real link/button, or stretch a link from CardTitle. Never bolt a click handler onto the surface.
  • Sibling cards in a grid should share one flat heading level so the outline stays valid.
  • CardSkeleton is aria-hidden; mark the region it stands in aria-busy so the wait is announced.
  • Content mounted in a card keeps its own wiring — a Table still needs its caption, a RadioGroup still needs an explicit group label. A visual CardTitle does not name them.