Skip to content
Monogem

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

A bordered box around arbitrary content. Media, Header, and Footer are added only when an instance needs them.

Full anatomy (non-interactive)

Weekly report
Generated every Monday from the last seven days of activity.
42 events across 6 projects. Two items need review before the report can be shared with the team.

Compact density

Design systemUpdated today
Task trackerUpdated today
Marketing siteUpdated today

Whole-card navigation

Getting started guide
The whole surface is one native link — no nested interactive descendants. Hover shifts the border; keyboard focus shows the ring.
Read the guide

Code Example

card-showcase.tsx
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

StateLook
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

TypeBehavior
Non-interactive (default)The container itself has no click target. Any interactivity lives in explicit nested controls (buttons, links) inside it.
Whole-card navigationThe 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

DensityPaddingGap 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.

TokenWhere used
--card / --card-foregroundContainer fill + default text
--muted-foregroundDescription text
--borderContainer edge
--ringFocus ring, whole-card navigation only
--radius-lgCorner radius — Radius already names "Cards, popovers" for this token
--shadow-smResting elevation — Shadows names "Cards, raised panels"
--scale-5 / --scale-4Padding: default / compact density
--scale-4 / --scale-3Gap 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.