React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

cardcontainerlayoutcomponenttailwind

Card

A container component with structured layout for displaying grouped content such as previews, summaries, or list items.

Use Cases

  • Display product listings with image, title, price, and action
  • Show user profile summaries in a dashboard
  • Present blog post previews with thumbnail and excerpt
  • Render pricing plan comparisons in a grid
  • Display notification or activity feed items
  • Show statistics or KPI widgets on a dashboard
  • Present team member bios in an about page

Simplest Implementation

interface CardProps {
  title: string;
  children: React.ReactNode;
}
 
export function Card({ title, children }: CardProps) {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
      <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
      <div className="mt-2 text-sm text-gray-600">{children}</div>
    </div>
  );
}

A static container that needs no client interactivity, so "use client" is omitted. The card uses a subtle border and shadow to visually separate it from the background.

Variations

Card with Image

import Image from "next/image";
 
interface ImageCardProps {
  src: string;
  alt: string;
  title: string;
  children: React.ReactNode;
}
 
export function ImageCard({ src, alt, title, children }: ImageCardProps) {
  return (
    <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
      <div className="relative h-48 w-full">
        <Image src={src} alt={alt} fill className="object-cover" />
      </div>
      <div className="p-6">
        <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
        <div className="mt-2 text-sm text-gray-600">{children}</div>
      </div>
    </div>
  );
}

Uses Next.js Image with fill and object-cover to handle responsive image sizing without layout shift. The image container has a fixed height so cards in a grid stay aligned.

Horizontal Card

import Image from "next/image";
 
interface HorizontalCardProps {
  src: string;
  alt: string;
  title: string;
  description: string;
}
 
export function HorizontalCard({ src, alt, title, description }: HorizontalCardProps) {
  return (
    <div className="flex overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
      <div className="relative h-auto w-48 shrink-0">
        <Image src={src} alt={alt} fill className="object-cover" />
      </div>
      <div className="p-6">
        <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
        <p className="mt-2 text-sm text-gray-600">{description}</p>
      </div>
    </div>
  );
}

A side-by-side layout with the image on the left. The shrink-0 on the image container prevents it from collapsing when the text content is long.

Clickable Card

"use client";
 
import Link from "next/link";
 
interface ClickableCardProps {
  href: string;
  title: string;
  description: string;
}
 
export function ClickableCard({ href, title, description }: ClickableCardProps) {
  return (
    <Link
      href={href}
      className="block rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-all hover:border-blue-300 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
    >
      <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
      <p className="mt-2 text-sm text-gray-600">{description}</p>
    </Link>
  );
}

Wrapping the entire card in a <Link> makes the whole surface clickable. The hover:shadow-md and hover:border-blue-300 provide clear visual feedback that the card is interactive.

Card with Badge/Status

type Status = "active" | "inactive" | "pending";
 
interface StatusCardProps {
  title: string;
  description: string;
  status: Status;
}
 
const statusClasses: Record<Status, string> = {
  active: "bg-green-100 text-green-700",
  inactive: "bg-gray-100 text-gray-700",
  pending: "bg-yellow-100 text-yellow-700",
};
 
const statusLabels: Record<Status, string> = {
  active: "Active",
  inactive: "Inactive",
  pending: "Pending",
};
 
export function StatusCard({ title, description, status }: StatusCardProps) {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
        <span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusClasses[status]}`}>
          {statusLabels[status]}
        </span>
      </div>
      <p className="mt-2 text-sm text-gray-600">{description}</p>
    </div>
  );
}

Uses a Record to map status values to both Tailwind classes and display labels. The badge sits in the card header using justify-between for clean alignment.

Card Grid Layout

interface CardGridProps {
  children: React.ReactNode;
  columns?: 2 | 3 | 4;
}
 
const columnClasses: Record<number, string> = {
  2: "grid-cols-1 sm:grid-cols-2",
  3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
  4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
};
 
export function CardGrid({ children, columns = 3 }: CardGridProps) {
  return (
    <div className={`grid gap-6 ${columnClasses[columns]}`}>
      {children}
    </div>
  );
}

A layout wrapper that arranges cards into a responsive grid. Columns collapse to a single column on mobile, then expand at breakpoints. The gap-6 keeps consistent spacing between cards.

"use client";
 
interface ActionCardProps {
  title: string;
  description: string;
  onPrimary: () => void;
  onSecondary?: () => void;
  primaryLabel: string;
  secondaryLabel?: string;
}
 
export function ActionCard({
  title, description, onPrimary, onSecondary, primaryLabel, secondaryLabel,
}: ActionCardProps) {
  return (
    <div className="flex flex-col rounded-xl border border-gray-200 bg-white shadow-sm">
      <div className="flex-1 p-6">
        <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
        <p className="mt-2 text-sm text-gray-600">{description}</p>
      </div>
      <div className="flex justify-end gap-3 border-t px-6 py-4">
        {onSecondary && secondaryLabel && (
          <button
            onClick={onSecondary}
            className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
          >
            {secondaryLabel}
          </button>
        )}
        <button
          onClick={onPrimary}
          className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
        >
          {primaryLabel}
        </button>
      </div>
    </div>
  );
}

Uses flex-col with flex-1 on the body so the footer always sits at the bottom, even when cards in a grid have different content heights.

Complex Implementation

"use client";
 
import { forwardRef, createContext, useContext } from "react";
import Image from "next/image";
import Link from "next/link";
 
// --- Context ---
 
interface CardContextValue {
  interactive: boolean;
}
 
const CardContext = createContext<CardContextValue>({ interactive: false });
 
// --- Root ---
 
type CardRootAsDiv = React.HTMLAttributes<HTMLDivElement> & {
  href?: never;
  variant?: "elevated" | "outlined" | "filled";
  children: React.ReactNode;
};
 
type CardRootAsLink = React.ComponentPropsWithoutRef<typeof Link> & {
  href: string;
  variant?: "elevated" | "outlined" | "filled";
  children: React.ReactNode;
};
 
type CardRootProps = CardRootAsDiv | CardRootAsLink;
 
const variantClasses: Record<string, string> = {
  elevated: "border border-gray-200 bg-white shadow-sm hover:shadow-md",
  outlined: "border border-gray-200 bg-white",
  filled: "bg-gray-50",
};
 
export const CardRoot = forwardRef<HTMLDivElement | HTMLAnchorElement, CardRootProps>(
  function CardRoot(props, ref) {
    const { variant = "elevated", children, className, ...rest } = props;
 
    const base = `rounded-xl transition-all ${variantClasses[variant]} ${className ?? ""}`;
    const isLink = "href" in rest && rest.href;
 
    if (isLink) {
      const { href, ...linkRest } = rest as CardRootAsLink;
      return (
        <CardContext.Provider value={{ interactive: true }}>
          <Link
            ref={ref as React.Ref<HTMLAnchorElement>}
            href={href}
            className={`block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${base}`}
            {...linkRest}
          >
            {children}
          </Link>
        </CardContext.Provider>
      );
    }
 
    return (
      <CardContext.Provider value={{ interactive: false }}>
        <div ref={ref as React.Ref<HTMLDivElement>} className={base} {...(rest as CardRootAsDiv)}>
          {children}
        </div>
      </CardContext.Provider>
    );
  }
);
 
// --- Compound Parts ---
 
export function CardImage({ src, alt, height = "h-48" }: { src: string; alt: string; height?: string }) {
  return (
    <div className={`relative ${height} w-full overflow-hidden rounded-t-xl`}>
      <Image src={src} alt={alt} fill className="object-cover" sizes="(max-width: 768px) 100vw, 33vw" />
    </div>
  );
}
 
export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="px-6 pt-6">{children}</div>;
}
 
export function CardTitle({ children }: { children: React.ReactNode }) {
  const { interactive } = useContext(CardContext);
  return (
    <h3 className={`text-lg font-semibold text-gray-900 ${interactive ? "group-hover:text-blue-600" : ""}`}>
      {children}
    </h3>
  );
}
 
export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="px-6 py-4 text-sm text-gray-600">{children}</div>;
}
 
export function CardFooter({ children }: { children: React.ReactNode }) {
  return <div className="flex items-center gap-3 border-t px-6 py-4">{children}</div>;
}
 
type BadgeColor = "gray" | "green" | "red" | "yellow" | "blue";
 
const badgeColors: Record<BadgeColor, string> = {
  gray: "bg-gray-100 text-gray-700",
  green: "bg-green-100 text-green-700",
  red: "bg-red-100 text-red-700",
  yellow: "bg-yellow-100 text-yellow-700",
  blue: "bg-blue-100 text-blue-700",
};
 
export function CardBadge({ children, color = "gray" }: { children: React.ReactNode; color?: BadgeColor }) {
  return (
    <span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${badgeColors[color]}`}>
      {children}
    </span>
  );
}
 
// --- Usage Example ---
// <CardRoot href="/products/1" variant="elevated">
//   <CardImage src="/product.jpg" alt="Product" />
//   <CardHeader>
//     <div className="flex items-center justify-between">
//       <CardTitle>Product Name</CardTitle>
//       <CardBadge color="green">In Stock</CardBadge>
//     </div>
//   </CardHeader>
//   <CardBody>A short product description goes here.</CardBody>
//   <CardFooter>
//     <span className="text-lg font-bold text-gray-900">$49.99</span>
//   </CardFooter>
// </CardRoot>

Key aspects:

  • Compound component patternCardRoot, CardImage, CardHeader, CardTitle, CardBody, CardFooter, and CardBadge compose freely. The parent never needs to know which sub-components are used.
  • Polymorphic root — discriminated union on href switches between <div> and <Link>. TypeScript enforces correct props for each.
  • forwardRef — allows parent components to attach refs for scroll-to, measurement, or animation libraries.
  • Context-aware stylingCardTitle reads context to apply hover color only when the card is a link, avoiding misleading interactive cues on static cards.
  • Responsive image with sizes — the sizes hint on <Image> tells the browser which image size to download at each viewport width, preventing oversized downloads on mobile.
  • Variant systemelevated, outlined, and filled variants cover common visual needs without custom class overrides.

Gotchas

  • Missing overflow-hidden on cards with images — Without it, the image corners poke out past the card's border-radius. Always add overflow-hidden to the card or the image container.

  • Inconsistent card heights in a grid — Cards with varying content lengths look ragged. Use flex flex-col on the card and flex-1 on the body so footers align at the bottom.

  • Clickable card with nested interactive elements — A <Link> card containing a <button> triggers the link when the button is clicked. Use e.stopPropagation() on the nested button or restructure so the link only covers the title.

  • Next.js <Image> without sizes prop — The fill prop generates a srcset, but without sizes, the browser assumes the image is 100vw and downloads the largest version. Always provide a sizes hint.

  • Card content overflowing — Long titles or descriptions without line-clamp break the layout. Use line-clamp-2 or truncate on text elements to enforce a maximum visible length.

  • Using <div> instead of <article> for content cards — If a card represents a standalone piece of content (blog post, product), use <article> for better semantics and accessibility.

  • Button — Action buttons used in card footers
  • Modal — Modals triggered from card actions
  • Tabs — Organizing card collections by category