Card
A container component with structured layout for displaying grouped content such as previews, summaries, or list items.
Search across all documentation pages
A container component with structured layout for displaying grouped content such as previews, summaries, or list items.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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.
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.
"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.
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.
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.
"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:
CardRoot, CardImage, CardHeader, CardTitle, CardBody, CardFooter, and CardBadge compose freely. The parent never needs to know which sub-components are used.href switches between <div> and <Link>. TypeScript enforces correct props for each.CardTitle reads context to apply hover color only when the card is a link, avoiding misleading interactive cues on static cards.sizes - the sizes hint on <Image> tells the browser which image size to download at each viewport width, preventing oversized downloads on mobile.elevated, outlined, and filled variants cover common visual needs without custom class overrides.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.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥