Episode 2 — React Frontend Architecture NextJS / 2.2 — React Components and Props
2.2.f — Reusable Card Component
In one sentence: Building a reusable Card component from scratch teaches you how to design flexible, composable component APIs using props, children, variants, and composition — the skills that separate junior from senior React developers.
Navigation: ← 2.2.e — Keys in React · Next → Overview
Table of Contents
- Why Build a Card?
- Requirements Gathering
- The Simplest Card
- Adding Variants
- Card Sub-Components
- Compound Component Pattern
- Interactive Cards
- Card Grid Layout
- Responsive Design
- Accessibility
- Real-World Card Examples
- Testing Your Card
- Design System Card Anatomy
- Key Takeaways
- Explain-It Challenge
1. Why Build a Card?
Cards are the most common UI pattern on the web. Every major platform uses them:
┌─────────────────────────────────────────────────────────────┐
│ CARDS EVERYWHERE │
│ │
│ Twitter/X → Tweet card (avatar, text, actions) │
│ YouTube → Video card (thumbnail, title, channel) │
│ Airbnb → Listing card (photo, price, rating) │
│ GitHub → Repo card (name, description, stars) │
│ Amazon → Product card (image, name, price, rating) │
│ LinkedIn → Post card (author, content, reactions) │
│ Spotify → Album/playlist card (cover, title, artist) │
│ Google Maps → Place card (photo, name, rating, hours) │
│ │
│ Pattern: Visual container + structured content + actions │
└─────────────────────────────────────────────────────────────┘
Building a Card teaches you:
- Component API design — what props to expose
- Composition — using
childrenand sub-components - Variants — one component, many appearances
- Flexibility vs. Simplicity — the design tradeoff
2. Requirements Gathering
Before writing code, define what the Card needs to support:
Minimum Viable Card
| Feature | Description |
|---|---|
| Container | Visual boundary (border, shadow, rounded corners) |
| Padding | Interior spacing |
| Children | Accept any content |
Full-Featured Card
| Feature | Description |
|---|---|
| Header | Title, subtitle, avatar, action button |
| Image/Media | Top image, side image, background image |
| Body | Main content area |
| Footer | Actions, links, metadata |
| Variants | Elevated, outlined, filled, horizontal |
| Interactive | Clickable, hoverable |
| Loading | Skeleton state |
3. The Simplest Card
Start with the absolute minimum and add features incrementally:
// Version 1: Bare minimum
function Card({ children }) {
return (
<div style={{
border: "1px solid #e0e0e0",
borderRadius: "8px",
padding: "16px",
backgroundColor: "#ffffff",
}}>
{children}
</div>
);
}
// Usage
<Card>
<h2>Hello World</h2>
<p>This is a simple card.</p>
</Card>
Version 2: Adding Basic Props
function Card({
children,
padding = "16px",
shadow = false,
rounded = true,
className = "",
style = {},
}) {
const baseStyle = {
border: "1px solid #e0e0e0",
borderRadius: rounded ? "8px" : "0",
padding,
backgroundColor: "#ffffff",
boxShadow: shadow ? "0 2px 8px rgba(0, 0, 0, 0.1)" : "none",
...style,
};
return (
<div className={`card ${className}`} style={baseStyle}>
{children}
</div>
);
}
// Usage
<Card shadow padding="24px">
<h2>Elevated Card</h2>
<p>This card has a shadow.</p>
</Card>
4. Adding Variants
function Card({
children,
variant = "outlined", // "outlined" | "elevated" | "filled"
padding = "16px",
className = "",
style = {},
}) {
const variantStyles = {
outlined: {
border: "1px solid #e0e0e0",
backgroundColor: "#ffffff",
boxShadow: "none",
},
elevated: {
border: "none",
backgroundColor: "#ffffff",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08)",
},
filled: {
border: "none",
backgroundColor: "#f5f5f5",
boxShadow: "none",
},
};
const baseStyle = {
borderRadius: "12px",
padding,
transition: "box-shadow 0.2s ease, transform 0.2s ease",
...variantStyles[variant],
...style,
};
return (
<div className={`card card-${variant} ${className}`} style={baseStyle}>
{children}
</div>
);
}
// Usage
<Card variant="outlined">Outlined card</Card>
<Card variant="elevated">Elevated card with shadow</Card>
<Card variant="filled">Filled background card</Card>
5. Card Sub-Components
Split the card into composable parts:
function CardImage({ src, alt, height = "200px", objectFit = "cover" }) {
return (
<div style={{
margin: "-16px -16px 16px -16px", // Bleed to card edges
overflow: "hidden",
borderRadius: "12px 12px 0 0",
}}>
<img
src={src}
alt={alt}
style={{
width: "100%",
height,
objectFit,
display: "block",
}}
/>
</div>
);
}
function CardHeader({ title, subtitle, avatar, action }) {
return (
<div style={{
display: "flex",
alignItems: "center",
gap: "12px",
marginBottom: "12px",
}}>
{avatar && (
<img
src={avatar}
alt=""
style={{ width: 40, height: 40, borderRadius: "50%", objectFit: "cover" }}
/>
)}
<div style={{ flex: 1 }}>
<h3 style={{ margin: 0, fontSize: "1rem", fontWeight: 600 }}>{title}</h3>
{subtitle && (
<p style={{ margin: "2px 0 0", fontSize: "0.875rem", color: "#666" }}>
{subtitle}
</p>
)}
</div>
{action && <div>{action}</div>}
</div>
);
}
function CardBody({ children }) {
return (
<div style={{ marginBottom: "12px" }}>
{children}
</div>
);
}
function CardFooter({ children, divider = true }) {
return (
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
paddingTop: divider ? "12px" : "0",
borderTop: divider ? "1px solid #e0e0e0" : "none",
marginTop: "auto",
}}>
{children}
</div>
);
}
// Full usage with sub-components
<Card variant="elevated" padding="0">
<CardImage src="/beach.jpg" alt="Beach sunset" />
<div style={{ padding: "16px" }}>
<CardHeader
title="Beach Vacation"
subtitle="Maldives · 5 nights"
avatar="/travel-agency.jpg"
action={<button>♡</button>}
/>
<CardBody>
<p>Experience crystal clear waters and white sand beaches.
All-inclusive resort package with spa and dining.</p>
</CardBody>
<CardFooter>
<span style={{ fontWeight: "bold", fontSize: "1.25rem" }}>$2,499</span>
<span style={{ color: "#666", fontSize: "0.875rem" }}>/person</span>
<button style={{ marginLeft: "auto", padding: "8px 20px", backgroundColor: "#2196f3", color: "white", border: "none", borderRadius: "6px", cursor: "pointer" }}>
Book Now
</button>
</CardFooter>
</div>
</Card>
6. Compound Component Pattern
The compound component pattern attaches sub-components directly to the parent:
function Card({ children, variant = "outlined", padding = "16px", style = {} }) {
const variantStyles = {
outlined: { border: "1px solid #e0e0e0", backgroundColor: "#fff", boxShadow: "none" },
elevated: { border: "none", backgroundColor: "#fff", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" },
filled: { border: "none", backgroundColor: "#f5f5f5", boxShadow: "none" },
};
return (
<div style={{
borderRadius: "12px",
padding,
overflow: "hidden",
...variantStyles[variant],
...style,
}}>
{children}
</div>
);
}
// Attach sub-components
Card.Image = function CardImage({ src, alt, height = "200px" }) {
return (
<img
src={src} alt={alt}
style={{ width: "100%", height, objectFit: "cover", display: "block", margin: "0 0 12px" }}
/>
);
};
Card.Header = function CardHeader({ title, subtitle, action }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
<div>
<h3 style={{ margin: 0, fontSize: "1.1rem" }}>{title}</h3>
{subtitle && <p style={{ margin: "4px 0 0", color: "#666", fontSize: "0.875rem" }}>{subtitle}</p>}
</div>
{action}
</div>
);
};
Card.Body = function CardBody({ children }) {
return <div style={{ marginBottom: "12px" }}>{children}</div>;
};
Card.Footer = function CardFooter({ children }) {
return (
<div style={{ display: "flex", gap: "8px", paddingTop: "12px", borderTop: "1px solid #eee" }}>
{children}
</div>
);
};
Card.Badge = function CardBadge({ children, color = "#2196f3" }) {
return (
<span style={{
display: "inline-block", padding: "2px 8px", borderRadius: "4px",
backgroundColor: color, color: "white", fontSize: "0.75rem", fontWeight: "bold",
}}>
{children}
</span>
);
};
// Usage — clean, discoverable API
<Card variant="elevated" padding="0">
<Card.Image src="/laptop.jpg" alt="Laptop" />
<div style={{ padding: "16px" }}>
<Card.Header
title="MacBook Pro M4"
subtitle="Apple"
action={<Card.Badge color="#4caf50">New</Card.Badge>}
/>
<Card.Body>
<p>The most powerful MacBook Pro ever. M4 Pro and M4 Max chips.</p>
</Card.Body>
<Card.Footer>
<span style={{ fontWeight: "bold" }}>$1,999</span>
<button style={{ marginLeft: "auto" }}>Add to Cart</button>
</Card.Footer>
</div>
</Card>
Why Compound Components Are Powerful
| Benefit | Description |
|---|---|
| Discoverability | Card. triggers autocomplete showing all sub-components |
| Namespacing | Card.Header can't be confused with a standalone Header |
| Flexibility | Use any combination of sub-components in any order |
| Cohesion | All related pieces live together |
| Import simplicity | One import (Card) gets everything |
7. Interactive Cards
Clickable Card
function ClickableCard({ children, onClick, href, variant = "outlined" }) {
const [isHovered, setIsHovered] = useState(false);
const interactiveStyle = {
cursor: "pointer",
transform: isHovered ? "translateY(-2px)" : "translateY(0)",
boxShadow: isHovered
? "0 8px 24px rgba(0, 0, 0, 0.15)"
: variant === "elevated"
? "0 2px 8px rgba(0, 0, 0, 0.12)"
: "none",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
};
const props = {
style: interactiveStyle,
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
};
// Render as <a> if href provided, otherwise <div> with onClick
if (href) {
return (
<a href={href} {...props} style={{ ...interactiveStyle, textDecoration: "none", color: "inherit", display: "block" }}>
<Card variant={variant}>{children}</Card>
</a>
);
}
return (
<div
{...props}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<Card variant={variant}>{children}</Card>
</div>
);
}
// Usage
<ClickableCard href="/products/1">
<Card.Header title="Wireless Headphones" subtitle="$79.99" />
<Card.Body><p>Premium sound quality</p></Card.Body>
</ClickableCard>
<ClickableCard onClick={() => setSelectedId(1)}>
<Card.Header title="Select this option" />
</ClickableCard>
Selectable Card
function SelectableCard({ selected, onSelect, children }) {
return (
<div
onClick={onSelect}
role="option"
aria-selected={selected}
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(); }}
style={{
border: selected ? "2px solid #2196f3" : "1px solid #e0e0e0",
borderRadius: "12px",
padding: "16px",
cursor: "pointer",
backgroundColor: selected ? "#e3f2fd" : "#ffffff",
transition: "all 0.2s ease",
outline: "none",
}}
>
{children}
{selected && (
<div style={{ textAlign: "right", marginTop: "8px" }}>
<span style={{ color: "#2196f3", fontWeight: "bold" }}>✓ Selected</span>
</div>
)}
</div>
);
}
// Usage — plan selection
function PricingCards() {
const [selectedPlan, setSelectedPlan] = useState("pro");
const plans = [
{ id: "free", name: "Free", price: "$0/mo", features: ["5 projects", "1 GB"] },
{ id: "pro", name: "Pro", price: "$19/mo", features: ["Unlimited", "100 GB", "Support"] },
{ id: "enterprise", name: "Enterprise", price: "$99/mo", features: ["Everything", "SLA", "SSO"] },
];
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px" }}>
{plans.map(plan => (
<SelectableCard
key={plan.id}
selected={selectedPlan === plan.id}
onSelect={() => setSelectedPlan(plan.id)}
>
<h3>{plan.name}</h3>
<p style={{ fontSize: "1.5rem", fontWeight: "bold" }}>{plan.price}</p>
<ul>
{plan.features.map(f => <li key={f}>{f}</li>)}
</ul>
</SelectableCard>
))}
</div>
);
}
8. Card Grid Layout
Responsive Grid with CSS Grid
function CardGrid({ children, minWidth = "280px", gap = "24px" }) {
return (
<div style={{
display: "grid",
gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}, 1fr))`,
gap,
}}>
{children}
</div>
);
}
// Usage
<CardGrid minWidth="300px" gap="16px">
{products.map(product => (
<Card key={product.id} variant="elevated">
<Card.Image src={product.image} alt={product.name} />
<div style={{ padding: "16px" }}>
<Card.Header title={product.name} subtitle={`$${product.price}`} />
<Card.Body><p>{product.description}</p></Card.Body>
</div>
</Card>
))}
</CardGrid>
Masonry-Style Layout
function MasonryGrid({ children, columns = 3, gap = "16px" }) {
// Distribute children across columns
const columnArrays = Array.from({ length: columns }, () => []);
React.Children.forEach(children, (child, index) => {
columnArrays[index % columns].push(child);
});
return (
<div style={{ display: "flex", gap }}>
{columnArrays.map((column, colIndex) => (
<div key={colIndex} style={{ flex: 1, display: "flex", flexDirection: "column", gap }}>
{column}
</div>
))}
</div>
);
}
9. Responsive Design
function ResponsiveCard({
image, title, subtitle, description, actions,
horizontal = false
}) {
// In a real app, you'd use CSS media queries or a hook
// This is the conceptual approach
if (horizontal) {
return (
<Card variant="outlined" padding="0">
<div style={{ display: "flex" }}>
{image && (
<img
src={image} alt={title}
style={{ width: "200px", objectFit: "cover" }}
/>
)}
<div style={{ padding: "16px", flex: 1 }}>
<h3 style={{ margin: "0 0 4px" }}>{title}</h3>
{subtitle && <p style={{ color: "#666", margin: "0 0 8px", fontSize: "0.875rem" }}>{subtitle}</p>}
{description && <p style={{ margin: "0 0 12px" }}>{description}</p>}
{actions && <div style={{ display: "flex", gap: "8px" }}>{actions}</div>}
</div>
</div>
</Card>
);
}
return (
<Card variant="outlined" padding="0">
{image && <Card.Image src={image} alt={title} />}
<div style={{ padding: "16px" }}>
<h3 style={{ margin: "0 0 4px" }}>{title}</h3>
{subtitle && <p style={{ color: "#666", margin: "0 0 8px", fontSize: "0.875rem" }}>{subtitle}</p>}
{description && <p style={{ margin: "0 0 12px" }}>{description}</p>}
{actions && <div style={{ display: "flex", gap: "8px" }}>{actions}</div>}
</div>
</Card>
);
}
10. Accessibility
Making Cards Accessible
function AccessibleCard({
children,
title,
onClick,
href,
variant = "outlined"
}) {
// Clickable card needs proper semantics
if (onClick || href) {
const Tag = href ? "a" : "div";
const interactiveProps = href
? { href }
: {
role: "button",
tabIndex: 0,
onClick,
onKeyDown: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
},
};
return (
<Tag
{...interactiveProps}
aria-label={title}
style={{
display: "block",
textDecoration: "none",
color: "inherit",
cursor: "pointer",
outline: "none",
borderRadius: "12px",
}}
>
<Card variant={variant}>{children}</Card>
</Tag>
);
}
// Non-interactive card — use <article> for semantic meaning
return (
<article aria-label={title}>
<Card variant={variant}>{children}</Card>
</article>
);
}
Accessibility Checklist
| Requirement | Implementation |
|---|---|
| Semantic HTML | Use <article> for content cards |
| Keyboard navigation | tabIndex={0}, handle Enter/Space for clickable cards |
| Focus indicator | Visible outline on :focus-visible |
| Alt text | All images must have descriptive alt |
| Color contrast | Text must have 4.5:1 contrast ratio |
| ARIA labels | aria-label on clickable cards |
| Role | role="button" for clickable non-link cards |
| Screen reader | Content order should make sense when read linearly |
11. Real-World Card Examples
GitHub Repository Card
function RepoCard({ repo }) {
const { name, description, language, stars, forks, updatedAt, owner } = repo;
const languageColors = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Rust: "#dea584",
Go: "#00ADD8",
};
return (
<Card variant="outlined">
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px" }}>
<img src={owner.avatar} alt={owner.name} style={{ width: 20, height: 20, borderRadius: "50%" }} />
<span style={{ color: "#666", fontSize: "0.875rem" }}>{owner.name}</span>
<span style={{ color: "#666" }}>/</span>
<a href={`/${owner.name}/${name}`} style={{ fontWeight: 600, color: "#2196f3", textDecoration: "none" }}>
{name}
</a>
</div>
{description && (
<p style={{ color: "#444", fontSize: "0.875rem", margin: "0 0 12px", lineHeight: 1.5 }}>
{description}
</p>
)}
<div style={{ display: "flex", gap: "16px", fontSize: "0.8rem", color: "#666" }}>
{language && (
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{
width: 12, height: 12, borderRadius: "50%",
backgroundColor: languageColors[language] || "#999",
display: "inline-block",
}} />
{language}
</span>
)}
<span>⭐ {stars.toLocaleString()}</span>
<span>🔀 {forks.toLocaleString()}</span>
<span>Updated {updatedAt}</span>
</div>
</Card>
);
}
Social Media Post Card
function PostCard({ post }) {
const { author, content, image, timestamp, likes, comments, shares } = post;
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(likes);
const handleLike = () => {
setLiked(!liked);
setLikeCount(prev => liked ? prev - 1 : prev + 1);
};
return (
<Card variant="outlined">
<CardHeader
avatar={author.avatar}
title={author.name}
subtitle={timestamp}
action={<button style={{ background: "none", border: "none", fontSize: "1.25rem", cursor: "pointer" }}>⋯</button>}
/>
<p style={{ margin: "0 0 12px", lineHeight: 1.6 }}>{content}</p>
{image && (
<img
src={image}
alt="Post attachment"
style={{ width: "100%", borderRadius: "8px", marginBottom: "12px" }}
/>
)}
{/* Engagement stats */}
<div style={{ display: "flex", justifyContent: "space-between", color: "#666", fontSize: "0.875rem", padding: "8px 0", borderBottom: "1px solid #eee" }}>
<span>{likeCount} likes</span>
<span>{comments} comments · {shares} shares</span>
</div>
{/* Action buttons */}
<div style={{ display: "flex", justifyContent: "space-around", paddingTop: "8px" }}>
<button
onClick={handleLike}
style={{
background: "none", border: "none", cursor: "pointer",
color: liked ? "#f44336" : "#666",
fontWeight: liked ? "bold" : "normal",
padding: "8px 16px", borderRadius: "4px",
}}
>
{liked ? "❤️" : "🤍"} Like
</button>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#666", padding: "8px 16px" }}>
💬 Comment
</button>
<button style={{ background: "none", border: "none", cursor: "pointer", color: "#666", padding: "8px 16px" }}>
↗️ Share
</button>
</div>
</Card>
);
}
Notification Card
function NotificationCard({ notification, onDismiss, onAction }) {
const { type, title, message, time, read, actionLabel } = notification;
const typeConfig = {
info: { icon: "ℹ️", accent: "#2196f3" },
success: { icon: "✅", accent: "#4caf50" },
warning: { icon: "⚠️", accent: "#ff9800" },
error: { icon: "❌", accent: "#f44336" },
};
const config = typeConfig[type] || typeConfig.info;
return (
<div style={{
display: "flex",
gap: "12px",
padding: "16px",
borderLeft: `4px solid ${config.accent}`,
borderRadius: "0 8px 8px 0",
backgroundColor: read ? "#ffffff" : "#f8f9fa",
transition: "background-color 0.2s",
}}>
<span style={{ fontSize: "1.5rem" }}>{config.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "4px" }}>
<strong style={{ fontSize: "0.95rem" }}>{title}</strong>
<span style={{ fontSize: "0.75rem", color: "#999" }}>{time}</span>
</div>
<p style={{ margin: "0 0 8px", color: "#555", fontSize: "0.875rem", lineHeight: 1.4 }}>
{message}
</p>
<div style={{ display: "flex", gap: "8px" }}>
{actionLabel && (
<button
onClick={onAction}
style={{
padding: "4px 12px", fontSize: "0.8rem",
backgroundColor: config.accent, color: "white",
border: "none", borderRadius: "4px", cursor: "pointer",
}}
>
{actionLabel}
</button>
)}
<button
onClick={onDismiss}
style={{
padding: "4px 12px", fontSize: "0.8rem",
backgroundColor: "transparent", color: "#999",
border: "1px solid #ddd", borderRadius: "4px", cursor: "pointer",
}}
>
Dismiss
</button>
</div>
</div>
{!read && (
<span style={{
width: 8, height: 8, borderRadius: "50%",
backgroundColor: config.accent,
flexShrink: 0, marginTop: "4px",
}} />
)}
</div>
);
}
12. Testing Your Card
What to Test
// Using React Testing Library (conceptual)
import { render, screen, fireEvent } from "@testing-library/react";
// Test 1: Renders children
test("renders card with children", () => {
render(<Card><p>Hello</p></Card>);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
// Test 2: Applies variant styles
test("elevated variant has box-shadow", () => {
const { container } = render(<Card variant="elevated">Content</Card>);
const card = container.firstChild;
expect(card.style.boxShadow).not.toBe("none");
});
// Test 3: Clickable card responds to clicks
test("clickable card calls onClick", () => {
const handleClick = jest.fn();
render(<ClickableCard onClick={handleClick}>Click me</ClickableCard>);
fireEvent.click(screen.getByText("Click me"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
// Test 4: Clickable card responds to keyboard
test("clickable card responds to Enter key", () => {
const handleClick = jest.fn();
render(<ClickableCard onClick={handleClick}>Press Enter</ClickableCard>);
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
expect(handleClick).toHaveBeenCalledTimes(1);
});
// Test 5: Sub-components render correctly
test("card header shows title and subtitle", () => {
render(
<Card>
<Card.Header title="My Title" subtitle="My Subtitle" />
</Card>
);
expect(screen.getByText("My Title")).toBeInTheDocument();
expect(screen.getByText("My Subtitle")).toBeInTheDocument();
});
// Test 6: Empty state
test("card without children renders empty container", () => {
const { container } = render(<Card />);
expect(container.firstChild).toBeEmptyDOMElement();
});
13. Design System Card Anatomy
┌─────────────────────────────────────────────────────────────┐
│ CARD ANATOMY │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ IMAGE AREA (optional) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ Full-bleed image │ │ │
│ │ │ aspect-ratio: 16/9 or 1/1 │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ HEADER (optional) │ │
│ │ ┌──────┐ ┌──────────────────┐ ┌───────┐ │ │
│ │ │Avatar│ │ Title │ │Action │ │ │
│ │ │ │ │ Subtitle │ │Button │ │ │
│ │ └──────┘ └──────────────────┘ └───────┘ │ │
│ │ │ │
│ │ BODY │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ Main content area. Text, images, forms, │ │ │
│ │ │ or any other content. Flexible height. │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ─────────────────── (divider) ────────────────── │ │
│ │ │ │
│ │ FOOTER (optional) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Button 1 │ │ Button 2 │ │ Metadata │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Design Tokens: │
│ • Border radius: 8-16px │
│ • Padding: 16-24px │
│ • Shadow (elevated): 0 2px 8px rgba(0,0,0,0.12) │
│ • Border (outlined): 1px solid #e0e0e0 │
│ • Background (filled): #f5f5f5 │
│ • Hover lift: translateY(-2px) + shadow increase │
│ • Transition: 200ms ease │
└─────────────────────────────────────────────────────────────┘
14. Key Takeaways
- Start simple — begin with the minimum viable Card (children + container), then add features
- Use variants — one Card component with a
variantprop beats multiple card components - Compound components —
Card.Header,Card.Body,Card.Footerprovide a clean API - Composition over props — prefer
childrenand sub-components over dozens of props - Interactive cards — handle click, hover, keyboard, and focus for clickable cards
- CSS Grid is perfect for card layouts —
auto-fill+minmax()for responsive grids - Accessibility matters — use
<article>,role="button",tabIndex, keyboard handlers - Test behavior — test clicks, keyboard events, variant rendering, content display
- Design tokens — consistent border-radius, padding, shadows, transitions across all cards
- Real-world cards combine multiple patterns — images, headers, badges, actions, states
Explain-It Challenge
-
The LEGO Set: A LEGO set has a base plate (Card), and you can snap on different pieces (Header, Body, Footer, Image) in any order. Explain how React's composition model makes the Card component like a LEGO base plate. What happens if you try to put every piece into the base plate as a prop instead?
-
The Restaurant Menu: Design a "dish card" for a restaurant app. What props would it need? What sub-components would you create? Walk through your design decisions — why did you choose props vs. children vs. sub-components for each piece?
-
The Evolution: You start with a simple
<Card>that just wraps content in a bordered box. Over six months, the product team asks for: images, header with avatar, badges, click actions, loading state, and horizontal layout. Explain how the compound component pattern handles this evolution better than adding more and more props.
Navigation: ← 2.2.e — Keys in React · Next → Overview