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

  1. Why Build a Card?
  2. Requirements Gathering
  3. The Simplest Card
  4. Adding Variants
  5. Card Sub-Components
  6. Compound Component Pattern
  7. Interactive Cards
  8. Card Grid Layout
  9. Responsive Design
  10. Accessibility
  11. Real-World Card Examples
  12. Testing Your Card
  13. Design System Card Anatomy
  14. Key Takeaways
  15. 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 children and 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

FeatureDescription
ContainerVisual boundary (border, shadow, rounded corners)
PaddingInterior spacing
ChildrenAccept any content

Full-Featured Card

FeatureDescription
HeaderTitle, subtitle, avatar, action button
Image/MediaTop image, side image, background image
BodyMain content area
FooterActions, links, metadata
VariantsElevated, outlined, filled, horizontal
InteractiveClickable, hoverable
LoadingSkeleton 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

BenefitDescription
DiscoverabilityCard. triggers autocomplete showing all sub-components
NamespacingCard.Header can't be confused with a standalone Header
FlexibilityUse any combination of sub-components in any order
CohesionAll related pieces live together
Import simplicityOne 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

RequirementImplementation
Semantic HTMLUse <article> for content cards
Keyboard navigationtabIndex={0}, handle Enter/Space for clickable cards
Focus indicatorVisible outline on :focus-visible
Alt textAll images must have descriptive alt
Color contrastText must have 4.5:1 contrast ratio
ARIA labelsaria-label on clickable cards
Rolerole="button" for clickable non-link cards
Screen readerContent 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

  1. Start simple — begin with the minimum viable Card (children + container), then add features
  2. Use variants — one Card component with a variant prop beats multiple card components
  3. Compound componentsCard.Header, Card.Body, Card.Footer provide a clean API
  4. Composition over props — prefer children and sub-components over dozens of props
  5. Interactive cards — handle click, hover, keyboard, and focus for clickable cards
  6. CSS Grid is perfect for card layouts — auto-fill + minmax() for responsive grids
  7. Accessibility matters — use <article>, role="button", tabIndex, keyboard handlers
  8. Test behavior — test clicks, keyboard events, variant rendering, content display
  9. Design tokens — consistent border-radius, padding, shadows, transitions across all cards
  10. Real-world cards combine multiple patterns — images, headers, badges, actions, states

Explain-It Challenge

  1. 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?

  2. 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?

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