Component · Containers & Overlays
Card
Overview
A generic container that groups related content and gives it a visible boundary — border and elevation separate it from the surrounding page. Card is deliberately generic: a flexible content boundary, not a fixed "marketing card" template with baked-in image/title/CTA anatomy. Every region below is optional except the container itself; compose what an instance actually needs.
Anatomy
Container → optional Media → optional Header (Title + Description + optional trailing action) → Content → optional Footer (actions).
- Media — an optional image or illustration region, edge-to-edge at the top of the card (no padding), sitting above the padded regions below.
- Header — a Title (Heading 4 recipe) plus an optional Description (Body small,
--muted-foreground), and an optional single trailing action (an icon-button) inline with the title. - Content — the card's main body. Free-form — Card imposes no anatomy inside it.
- Footer — an optional action row, typically one or two Buttons, aligned right or stretched full-width depending on context.
Container plus Content is the minimum valid Card — a bordered box around arbitrary content, with Media, Header, and Footer added only when the instance needs them.
Live Example
Minimum — container + Content
Full anatomy (non-interactive)
Compact density
Whole-card navigation
Code Example
import { ArrowRight, MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardMedia,
CardTitle,
} from "@/components/ui/card";
/**
* Live Card example — Card is a server component (no client JS). Shows the
* minimum valid Card (container + Content), the full optional anatomy (Media →
* Header with a trailing CardAction → Content → Footer), the `compact` density,
* and whole-card navigation (`href`) with its hover + `:focus-visible`
* treatment.
*
* The full-anatomy card is deliberately NOT interactive — it holds nested
* controls (the action icon-button, the footer buttons), so per card.md the
* container itself is never a link; the whole-card `href` card carries no
* nested interactive descendants.
*/
export function CardShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Minimum — container + Content
</h3>
<Card className="max-w-sm">
<CardContent>
A bordered box around arbitrary content. Media, Header, and Footer
are added only when an instance needs them.
</CardContent>
</Card>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Full anatomy (non-interactive)
</h3>
<Card className="max-w-sm">
<CardMedia>
<div className="aspect-[16/9] w-full bg-muted" />
</CardMedia>
<CardHeader>
<CardTitle>Weekly report</CardTitle>
<CardDescription>
Generated every Monday from the last seven days of activity.
</CardDescription>
<CardAction>
<Button size="icon" variant="ghost" aria-label="Report options">
<MoreHorizontal />
</Button>
</CardAction>
</CardHeader>
<CardContent>
42 events across 6 projects. Two items need review before the report
can be shared with the team.
</CardContent>
<CardFooter className="justify-end">
<Button variant="ghost">Dismiss</Button>
<Button>Review</Button>
</CardFooter>
</Card>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Compact density
</h3>
<div className="flex max-w-sm flex-col gap-3">
{["Design system", "Task tracker", "Marketing site"].map((name) => (
<Card key={name} density="compact">
<CardContent className="flex items-center justify-between gap-3">
<span className="text-sm font-medium leading-snug">{name}</span>
<span className="text-sm leading-normal text-muted-foreground">
Updated today
</span>
</CardContent>
</Card>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Whole-card navigation
</h3>
<Card href="#card-showcase" className="max-w-sm">
<CardHeader>
<CardTitle>Getting started guide</CardTitle>
<CardDescription>
The whole surface is one native link — no nested interactive
descendants. Hover shifts the border; keyboard focus shows the
ring.
</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-1 text-sm font-medium leading-snug">
Read the guide
<ArrowRight className="size-4 [stroke-width:var(--icon-stroke-width)]" />
</CardContent>
</Card>
</section>
</div>
);
}States
| State | Look |
|---|---|
| Default | --card fill, --border edge, --shadow-sm |
| Hover (whole-card navigation only) | Border shifts --border → --foreground, or a one-step shadow lift — pick one, not both |
| Focus-visible (whole-card navigation only) | --ring on :focus-visible (keyboard focus only) — same offset geometry as Button (a 2px gap between the card edge and the ring), the system's other geometry being the flush field ring (Color — Border width) |
| Disabled (whole-card navigation only) | Reduced opacity (opacity-50; Color — Disabled dimming), no pointer events, tabindex="-1" — aria-disabled alone wouldn't remove the link from the tab order. Rare; most non-actionable cards just don't render a link |
Non-interactive cards have no hover, focus, or disabled state — nothing about the container itself is ever interactive.
Usage Guidance
Interactive vs. non-interactive Card
| Type | Behavior |
|---|---|
| Non-interactive (default) | The container itself has no click target. Any interactivity lives in explicit nested controls (buttons, links) inside it. |
| Whole-card navigation | The entire card is a single <a> leading to one destination — the whole surface is the click target, wrapped in a native anchor, not a <div> with a synthetic onClick. Valid only when the card has no nested links, buttons, form controls, or other interactive descendants. Gets hover and :focus-visible treatment on the whole surface, same focus contract as Button — including its offset ring geometry. |
A generic Card is never wrapped in a native <button> to make the whole surface an action.
Card's Content region is free-form — it may hold headings, regions, lists, or other content that
doesn't belong inside a button's content model, and a <button> can't legally contain another
focusable control. A whole-card action (as opposed to single-destination navigation) is always an
explicit action button placed inside the Card, never the container itself promoted to a <button>.
Don't combine whole-card navigation with a nested interactive control. A whole-card link and a nested interactive control inside it create the same competing-target problem Checkbox already documents for a label with a nested link: the outer and inner targets fight over the same click, and the outer link's accessible name becomes ambiguous — it reads out everything inside it, images included. When a card contains nested controls, or leads to more than one destination or action:
- The Card container itself is not interactive — no wrapping
<a>, no wrapping<button>. - The primary destination or action is carried by an explicit link or button inside the Card (e.g. a linked title, or a visible "View" button), never implied by clicking the surface.
Default to a non-interactive card with an explicit nested Button or link for any action; reserve whole-card-as-link for a card that only ever does one thing (navigate to one destination) and contains no other interactive descendants.
Click-target boundaries
- Whole-card navigation: hit area is the full card surface, corner radius included.
- Non-interactive card: the container contributes no hit area of its own — nested controls keep their normal, independent hit areas.
Content density
| Density | Padding | Gap between stacked regions |
|---|---|---|
| Default | --scale-5 (20px) | --scale-4 (16px) |
| Compact | --scale-4 (16px) | --scale-3 (12px) |
Compact is for denser lists of small cards (a settings list, a compact grid); default is for standalone or few-per-view cards.
When not to use a Card
- A single form field — that's Input's own bordered field, not a Card.
- Two pieces of content already separated by ordinary page spacing — don't wrap every section in a Card "for structure"; that's Layout's job.
- A full page section — Card is a bounded content unit, not a page-layout primitive.
Tokens
Card uses existing Monogem semantic tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--card / --card-foreground | Container fill + default text |
--muted-foreground | Description text |
--border | Container edge |
--ring | Focus ring, whole-card navigation only |
--radius-lg | Corner radius — Radius already names "Cards, popovers" for this token |
--shadow-sm | Resting elevation — Shadows names "Cards, raised panels" |
--scale-5 / --scale-4 | Padding: default / compact density |
--scale-4 / --scale-3 | Gap between stacked regions: default / compact density |
Do / Don’t
Do
- Treat Media, Header, Content, and Footer as independently optional — compose only what's needed.
- Use a native
<a>for whole-card navigation, never a<div>with a synthetic click handler. - Use an explicit action button inside the Card for a whole-card action — never wrap the
container itself in a
<button>. - Pick one density (default or compact) per list of cards — don't mix within the same group.
Don’t
- Build a fixed "marketing card" variant taxonomy (pricing card, stat card, testimonial card) — compose each from this generic anatomy per instance instead.
- Wrap a generic Card's content in a native
<button>to create a whole-card action. - Nest an interactive control inside a whole-card link — if a card needs a nested control, make the card non-interactive and carry the primary destination or action with an explicit inner link or button instead.
- Reach for Card as a generic spacing/structure wrapper where page spacing already reads as separated content.
Accessibility
- Whole-card navigation: a native
<a>wraps the whole surface, valid only when the card has no interactive descendants — keyboard operability and the accessibility tree come from the native element, no ARIA needed. - Non-interactive card: no role. It's a plain container, not announced as a landmark or region. A
role="region"(with an accessible name) is justified only when a card is the sole signal of a genuine content boundary a screen-reader user needs to navigate to directly — the same "semantic only when it's the sole signal" principle Separator uses; most cards don't meet that bar. - The heading level used inside a card's Header is contextual — pick whatever level is correct for the page's actual outline. The "Heading 4" text recipe describes appearance only, not a fixed semantic level.
Responsive Behavior
A card laying Media and Content side-by-side (a "horizontal card") stacks them vertically — Media
on top, full width — below --breakpoint-sm, matching Grids'
mobile-first, min-width convention. No bespoke breakpoint.