Card
Un componente contenedor con diseño estructurado para mostrar contenido agrupado como previsualizaciones, resúmenes o elementos de lista.
Casos de uso
- Mostrar listados de productos con imagen, título, precio y acción
- Mostrar resúmenes de perfiles de usuario en un panel
- Presentar previsualizaciones de posts de blog con miniatura y extracto
- Renderizar comparaciones de planes de precios en una cuadrícula
- Mostrar elementos de notificación o feed de actividad
- Mostrar estadísticas o widgets de KPI en un panel
- Presentar biografías de miembros del equipo en una página de acerca de
Implementación más simple
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.
Variaciones
Tarjeta con imagen
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.
Tarjeta horizontal
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.
Tarjeta clickeable
"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.
Tarjeta con badge/estado
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.
Diseño de cuadrícula de tarjetas
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.
Tarjeta con acciones de pie de página
"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.
Implementación compleja
"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:
- Patrón de componente compuesto —
CardRoot,CardImage,CardHeader,CardTitle,CardBody,CardFooteryCardBadgese componen libremente. El componente padre nunca necesita saber qué subcomponentes se utilizan. - Raíz polimórfica — la unión discriminada en
hrefcambia entre<div>y<Link>. TypeScript impone props correctos para cada uno. - forwardRef — permite que los componentes padres adjunten refs para desplazarse a, medición o bibliotecas de animación.
- Estilos conscientes del contexto —
CardTitlelee el contexto para aplicar color de hover solo cuando la tarjeta es un enlace, evitando indicaciones interactivas engañosas en tarjetas estáticas. - Imagen responsiva con
sizes— la sugerenciasizesen<Image>le dice al navegador qué tamaño de imagen descargar en cada ancho de ventana, evitando descargas excesivas en móvil. - Sistema de variantes — las variantes
elevated,outlinedyfilledcubren necesidades visuales comunes sin reemplazos de clase personalizados.
Trampas
-
Falta de
overflow-hiddenen tarjetas con imágenes — Sin él, las esquinas de la imagen se asoman más allá delborder-radiusde la tarjeta. Siempre agregaoverflow-hiddena 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-colen la tarjeta yflex-1en 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. Usae.stopPropagation()en el botón anidado o reestructura el enlace para que solo cubra el título. -
Next.js
<Image>sin propsizes— El propfillgenera unsrcset, pero sinsizes, el navegador asume que la imagen es 100vw y descarga la versión más grande. Siempre proporciona una sugerenciasizes. -
Contenido de tarjeta desbordando — Los títulos o descripciones largos sin
line-clamprompen el diseño. Usaline-clamp-2otruncateen 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.