Card
Un componente contenedor con diseño estructurado para mostrar contenido agrupado como previsualizaciones, resúmenes o elementos de lista.
Busca en todas las páginas de la documentación
Un componente contenedor con diseño estructurado para mostrar contenido agrupado como previsualizaciones, resúmenes o elementos de lista.
🤖 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>
);
}Un contenedor estático que no necesita interactividad de cliente, por lo que se omite "use client". La tarjeta utiliza un borde sutil y sombra para separarla visualmente del fondo.
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>
);
}Utiliza Next.js Image con fill y object-cover para manejar el tamaño de imagen responsivo sin cambios de layout. El contenedor de imagen tiene una altura fija para que las tarjetas en una cuadrícula permanezcan alineadas.
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>
);
}Un diseño lado a lado con la imagen a la izquierda. El shrink-0 en el contenedor de imagen evita que se contraiga cuando el contenido de texto es largo.
"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>
);
}Envolver la tarjeta completa en un <Link> hace toda la superficie clickeable. Los hover:shadow-md y hover:border-blue-300 proporcionan retroalimentación visual clara de que la tarjeta es interactiva.
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>
);
}Utiliza un Record para asignar valores de estado a clases de Tailwind y etiquetas de visualización. El badge se encuentra en el encabezado de la tarjeta usando justify-between para una alineación limpia.
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>
);
}Un contenedor de diseño que organiza tarjetas en una cuadrícula responsiva. Las columnas se contraen a una sola columna en móvil, luego se expanden en puntos de ruptura. El gap-6 mantiene espaciado consistente entre tarjetas.
"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>
);
}Utiliza flex-col con flex-1 en el cuerpo para que el pie de página siempre se encuentre en la parte inferior, incluso cuando las tarjetas en una cuadrícula tienen diferentes alturas de contenido.
"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>Aspectos clave:
CardRoot, CardImage, CardHeader, CardTitle, CardBody, CardFooter y CardBadge se componen libremente. El componente padre nunca necesita saber qué subcomponentes se utilizan.href cambia entre <div> y <Link>. TypeScript impone props correctos para cada uno.CardTitle lee el contexto para aplicar color de hover solo cuando la tarjeta es un enlace, evitando indicaciones interactivas engañosas en tarjetas estáticas.sizes - la sugerencia sizes en <Image> le dice al navegador qué tamaño de imagen descargar en cada ancho de ventana, evitando descargas excesivas en móvil.elevated, outlined y filled cubren necesidades visuales comunes sin reemplazos de clase personalizados.Falta de overflow-hidden en tarjetas con imágenes - Sin él, las esquinas de la imagen se asoman más allá del border-radius de la tarjeta. Siempre agrega overflow-hidden a la tarjeta o al contenedor de imagen.
Alturas de tarjeta inconsistentes en una cuadrícula - Las tarjetas con longitudes de contenido variables se ven irregulares. Usa flex flex-col en la tarjeta y flex-1 en el cuerpo para que los pies de página se alineen en la parte inferior.
Tarjeta clickeable con elementos interactivos anidados - Una tarjeta <Link> que contiene un <button> activa el enlace cuando se hace clic en el botón. Usa e.stopPropagation() en el botón anidado o reestructura el enlace para que solo cubra el título.
Next.js <Image> sin prop sizes - El prop fill genera un srcset, pero sin sizes, el navegador asume que la imagen es 100vw y descarga la versión más grande. Siempre proporciona una sugerencia sizes.
Contenido de tarjeta desbordando - Los títulos o descripciones largos sin line-clamp rompen el diseño. Usa line-clamp-2 o truncate en elementos de texto para forzar una longitud máxima visible.
Usar <div> en lugar de <article> para tarjetas de contenido - Si una tarjeta representa un contenido independiente (post de blog, producto), usa <article> para mejor semántica y accesibilidad.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥