Card
A content surface that groups related content and actions into one unit.
@garn/ui/cardOn 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.
Stretched Link
A rich nav card: the whole surface is clickable, but only the title is a real link — so one descriptive link is announced.
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.
npx garn-ui add card- 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
| Selector | State | Description |
|---|---|---|
[data-slot=card]:hover | hover | Interactive cards only: a whisper-quiet state-layer tint under one neutral ramp step, plus the faintest raised shadow. |
[data-slot=card]:active | active | Interactive cards only: the state layer deepens. The card itself never moves. |
[data-slot=card]:focus-within | focus-visible | One ring around the whole card — focusing an inner title-link rings the card, not just the link. |
[data-slot=card][data-disabled] | disabled | Interactive cards only: aria-disabled, no pointer events, and click / Enter / Space blocked at the capture phase. |
| loading | Use CardSkeleton in place of the card's content, and mark the surrounding region aria-busy. |
| empty | Mount Empty bare (not inside CardContent) so the two surfaces don't double-pad. |
| Property | Token | Tier |
|---|---|---|
padding | --garn-pad-surface | semantic · air |
headerGap | --garn-gap-field | semantic · air |
frameStrip | --garn-space-3 | primitive |
frameStripAlpha | --garn-frame-strip-alphabrandable | semantic |
transitionDuration | --garn-motion-base | semantic |
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
- 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 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
dividedon 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 appearance — raised → elevated, hairline → outline.
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
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
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
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
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
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>
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
CardSkeleton is aria-hidden; the region it stands in should carry aria-busy so the wait is announced.
when CardSkeleton is rendered
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
Use `appearance` instead — `raised` → `elevated`, `hairline` → `outline`. `elevation` is ignored when `appearance` is set.
when the deprecated `elevation` prop is passed
Keyboard
| Tab | Moves 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. |
| Enter | Activates a card promoted to a link or button via asChild. Blocked while disabled. |
| Space | Activates a card promoted to a button via asChild. Blocked while disabled. |
- 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.