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.
Card with Footer Actions
"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 pattern —
CardRoot,CardImage,CardHeader,CardTitle,CardBody,CardFooter, andCardBadgecompose freely. The parent never needs to know which sub-components are used. - Polymorphic root — discriminated union on
hrefswitches 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 styling —
CardTitlereads context to apply hover color only when the card is a link, avoiding misleading interactive cues on static cards. - Responsive image with
sizes— thesizeshint on<Image>tells the browser which image size to download at each viewport width, preventing oversized downloads on mobile. - Variant system —
elevated,outlined, andfilledvariants cover common visual needs without custom class overrides.
Gotchas
-
Missing
overflow-hiddenon cards with images — Without it, the image corners poke out past the card'sborder-radius. Always addoverflow-hiddento the card or the image container. -
Inconsistent card heights in a grid — Cards with varying content lengths look ragged. Use
flex flex-colon the card andflex-1on 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. Usee.stopPropagation()on the nested button or restructure so the link only covers the title. -
Next.js
<Image>withoutsizesprop — Thefillprop generates asrcset, but withoutsizes, the browser assumes the image is 100vw and downloads the largest version. Always provide asizeshint. -
Card content overflowing — Long titles or descriptions without
line-clampbreak the layout. Useline-clamp-2ortruncateon 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.