콘텐츠로 이동

Card — spec

Status: example artifact for component-spec-writer skill Generated by: skills/component-spec-writer, citing Ant Design Card, MUI Card, shadcn-ui card

Purpose

A card groups related content into a single contained surface. Cards are the workhorse of dashboards, lists, settings, and landing pages. Easy to over-use — every group does NOT need to be a card.

When to use a card vs alternatives

Pattern Use
Card (with border or background) Group is genuinely a unit of content the user might select, navigate to, or compare.
Section (no border, just heading + whitespace) Logical grouping where boundary doesn't matter. Default for forms.
List item Many comparable items in a feed. Cards in a vertical list = visual noise.
Tile (square, image-led) Grid of equal items where image dominates.

Rule of thumb: if you remove the card border and the grouping is still clear from spacing + typography, don't use a card.

Anatomy

Card (root surface)
├── [Card.Cover]?        full-bleed image/media at top
├── Card.Header
│   ├── [Card.HeaderActions]?   (top-right slot — menu, close)
│   ├── Card.Title
│   └── [Card.Description]?
├── Card.Body            (main content)
└── [Card.Footer]?       (actions, metadata)
Part Required Notes
Card yes the container
Cover optional full-bleed image; sits above header
Header typically unless body is self-titling (e.g., a metric card with the metric as the only content)
Title yes if Header present semantic <h3> or <h4> based on page hierarchy
Description optional secondary line under title
HeaderActions optional top-right; menu button, close, status badge
Body yes primary content
Footer optional actions, timestamps, metadata

API (composition-style)

<Card>
  <Card.Cover src="..." alt="..." />
  <Card.Header>
    <Card.Title>Project Aurora</Card.Title>
    <Card.Description>Design system for the new dashboard</Card.Description>
    <Card.HeaderActions>
      <IconButton aria-label="More options" />
    </Card.HeaderActions>
  </Card.Header>
  <Card.Body>
    Body content...
  </Card.Body>
  <Card.Footer>
    <Button variant="outline">View</Button>
  </Card.Footer>
</Card>
Prop (root) Type Default Description
variant "outline" \| "filled" \| "elevated" "outline" Visual treatment.
interactive boolean false Hover/focus states; entire card clickable.
as ElementType "div" Render-as. Use "a" or "button" when interactive.
href string If set, root renders as <a> for navigation.
onClick (e: MouseEvent) => void Sets interactive=true.
padding "none" \| "sm" \| "md" \| "lg" "md" Internal padding. none for image-led cards.

Variants

variant

Value Background Border Shadow Use
outline (default) --color-bg-default 1px --color-border-default none Default — clean, neutral. Most cards.
filled --color-bg-elevated none none Subtle backdrop without a border line.
elevated --color-bg-default none --shadow-2 Lifted feel — landing pages, marketing. Avoid in dense UIs.

Choose one variant per surface and stick to it. Mixing outline and elevated cards on the same page reads as inconsistent.

Interactive cards

interactive={true} (or providing onClick/href) adds: - Cursor pointer - Hover: bg shift to --color-bg-subtle (outline/filled) or shadow strengthens (elevated) - Focus-visible: 2px ring (same as Button) - The full card is clickable, not just the title.

Required: render as <a> (with href) or <button> — not <div onClick>. Cite knowledge/a11y/keyboard-and-focus.md.

If the card has both a primary navigation AND inner buttons (e.g., card click navigates, footer has "Edit" and "Delete"), use the "block link" pattern: - The whole card is wrapped in <a>. - Inner buttons stop propagation and have higher z-index. - The card title's text is the inner <a> for screen reader, with the rest visually clickable via ::before pseudo.

Padding rules

padding Header Body Footer Use
sm 12px 12px 12px Dense lists, list-as-cards
md (default) 16px 16px 16px Standard product cards
lg 24px 24px 24px Marketing, hero cards
none 0 0 0 Image-led cards where Cover takes full width

For image-led cards: padding="none", manually pad the text sections. The cover should bleed to the card's border-radius edge.

Tokens consumed

--color-bg-default
--color-bg-elevated
--color-bg-subtle              (interactive hover)
--color-border-default
--color-text-primary
--color-text-secondary
--space-md
--space-lg
--radius-lg                    (cards usually larger than buttons)
--shadow-2                     (elevated variant)
--transition-fast              (interactive hover)

Accessibility

  • Default: <div> is fine for non-interactive cards. Title inside should be a heading (<h3> typically).
  • Interactive: render as <a> (navigation) or <button> (action). Keyboard reachable via Tab. Activates with Enter (always) and Space (button only).
  • Block link pattern when card has primary nav + inner controls: only one focus stop for the card's primary purpose; inner buttons get their own focus stops. Don't nest interactive elements.
  • Heading hierarchy: card titles should not all be <h2>. Match the page's hierarchy. A card on a <h1> page might use <h3> for its title — never skip a level.
  • Image alt text: Cover image needs alt describing meaning, not "card image". If decorative, alt="".
  • Empty state: a card with no content should not render an empty box. Render a placeholder with a clear CTA, or omit the card entirely.
  • Focus indicator: 2px ring on the card (interactive only), 2px offset from border. Same --color-focus-ring as Button.

Code examples

// Static info card — content card
<Card>
  <Card.Header>
    <Card.Title>Total revenue</Card.Title>
    <Card.Description>Last 30 days</Card.Description>
  </Card.Header>
  <Card.Body>
    <p className="text-3xl font-semibold">12,847,500</p>
    <p className="text-sm text-text-secondary">+12% vs prior period</p>
  </Card.Body>
</Card>

// Interactive card — navigates on click
<Card interactive href="/projects/aurora">
  <Card.Cover src="/aurora-thumb.jpg" alt="Aurora project preview" />
  <Card.Header>
    <Card.Title>Project Aurora</Card.Title>
    <Card.Description>Design system rebuild</Card.Description>
  </Card.Header>
  <Card.Body>
    <p>12 components shipped this week.</p>
  </Card.Body>
</Card>

// Card with menu + actions
<Card>
  <Card.Header>
    <Card.Title>Notifications</Card.Title>
    <Card.HeaderActions>
      <IconButton aria-label="Settings"><SettingsIcon /></IconButton>
    </Card.HeaderActions>
  </Card.Header>
  <Card.Body>
    <NotificationList items={notifs} />
  </Card.Body>
  <Card.Footer>
    <Button variant="link">See all</Button>
  </Card.Footer>
</Card>

// Block-link pattern
<Card interactive className="relative">
  <Card.Header>
    <Card.Title>
      <a href="/post/123" className="absolute inset-0 z-0" aria-label="Read post: How we shipped Aurora" />
      How we shipped Aurora
    </Card.Title>
  </Card.Header>
  <Card.Body>
    <p>Last week we wrapped a 3-month effort to...</p>
  </Card.Body>
  <Card.Footer className="relative z-10">
    <Button onClick={(e) => { e.stopPropagation(); like(); }}>Like</Button>
    <Button onClick={(e) => { e.stopPropagation(); share(); }}>Share</Button>
  </Card.Footer>
</Card>

Edge cases

  • Card grid that wraps awkwardly at certain widths: use repeat(auto-fit, minmax(280px, 1fr)) to set a sensible min before wrapping.
  • Card with image of unknown aspect ratio: use aspect-ratio CSS to enforce a ratio for the cover; lazy-load with loading="lazy".
  • Cards in dark mode look flat: outline variant works as-is. elevated shadow becomes ineffective on dark bg — increase shadow opacity OR switch to a +2% lighter background instead of shadow.
  • Long text in card body: don't truncate by default. Truncate only in space-constrained layouts (compact list cards) and provide a "see more" path.
  • Long titles: wrap to 2 lines, then truncate with . Provide title attribute with full text.
  • Skeleton loading: render the same shape (header + body + footer) with skeleton placeholders rather than a generic block. Cite knowledge/patterns/ux-guidelines.md.

Don't

  • Don't put cards inside cards. The visual nesting reads chaotic. Use sections within a card instead.
  • Don't make every grouping a card. Use whitespace + headings — they're free.
  • Don't mix card variants on the same page (e.g., some elevated, some outline).
  • Don't put a primary CTA in a card without context — a card is a container, not a top-level surface.
  • Don't make a card interactive AND put another interactive surface inside without using the block-link pattern. Nested clickable is broken.

References

API choices made: - Composition over prop overload: matches all three references' direction. Single <Card title=... description=... actions=...> API was rejected — slot-based scales. - Card.HeaderActions as a slot inside Card.Header rather than a separate top-level child: keeps the action's positional semantics tied to the header, making CSS simpler. - variant over MUI's elevation number: 3 named variants are easier to choose than 24 shadow elevation steps. (MUI's elevation={1..24} is over-flexible.) - interactive prop OR onClick/href infers it: ergonomic — common case requires no extra prop, advanced case (interactive without click handler — rare) is still expressible.

Cross-reference