Card
BetaUna superficie que agrupa contenido relacionado sobre un mismo tema. Se arma con partes; los casos de uso son recetas.
registry/ds/card.tsxEquipo académico
Coordinadores y docentes responsables del programa y su acompañamiento.
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardDescription, CardIcon, CardTitle } from "@/registry/ds/card";
import { CheckCircle } from "@/registry/ds/icons";
export default function CardBasic() {
return (
<div className="w-80">
<Card>
<CardBody>
<CardIcon>
<CheckCircle />
</CardIcon>
<CardTitle>Equipo académico</CardTitle>
<CardDescription>Coordinadores y docentes responsables del programa y su acompañamiento.</CardDescription>
<div className="mt-3">
<Button variant="tonal" size="sm">Conocer al equipo</Button>
</div>
</CardBody>
</Card>
</div>
);
}Cuándo usarlo
Una card es una unidad de contenido que puede vivir sola: un curso, un pago, una persona. Varias cards juntas se comparan y se escanean.
- Un tema por card. Si necesita tabs adentro, es una página.
- En una grilla, todas las cards comparten estructura y alto.
- No metas cards dentro de cards.
- No uses una card solo para "enmarcar" una sección: para eso alcanza el espaciado.
Partes y recetas
En Figma, el set Card tiene 19 tipos (Feature, Tarea, Equipo, Pago, Media, Membresía…). En código hay una sola superficie y seis partes; cada tipo de Figma es una combinación.
Maestría en Ciencia de Datos
Dos años, modalidad híbrida. Inscripciones hasta el 30 de marzo.
Matrícula 2026-1
Vence el 10 de abril.
$99.00
import { Avatar, AvatarGroup } from "@/registry/ds/avatar";
import { Badge, Chip } from "@/registry/ds/badge";
import { Card, CardBody, CardDescription, CardFooter, CardMedia, CardTitle } from "@/registry/ds/card";
export default function CardRecipes() {
return (
<div className="grid w-full max-w-2xl gap-5 sm:grid-cols-2">
{/* Receta "Media": toda la card es un link */}
<Card href="#" variant="elevated">
<CardMedia>
<div className="bg-linear-to-br from-primary-subtle to-info-subtle" />
</CardMedia>
<CardBody>
<div className="flex gap-2">
<Badge size="sm">Nuevo</Badge>
<Badge size="sm" variant="stroke">Posgrado</Badge>
</div>
<CardTitle>Maestría en Ciencia de Datos</CardTitle>
<CardDescription>Dos años, modalidad híbrida. Inscripciones hasta el 30 de marzo.</CardDescription>
</CardBody>
</Card>
{/* Receta "Pago": footer con estado */}
<Card padding="sm">
<CardBody>
<CardTitle as="h4">Matrícula 2026-1</CardTitle>
<CardDescription>Vence el 10 de abril.</CardDescription>
<p className="mt-2 text-heading-xs font-medium text-fg">$99.00</p>
</CardBody>
<CardFooter>
<AvatarGroup size="xs" label="Responsables de pago">
<Avatar size="xs" name="Julia Arce" />
<Avatar size="xs" name="Martín Ferro" />
</AvatarGroup>
<Chip status="warning">Pendiente</Chip>
</CardFooter>
</Card>
</div>
);
}| Parte | Para qué |
|---|---|
Card | La superficie. href la vuelve un link completo. |
CardMedia | Imagen a sangre, arriba. |
CardBody | El contenido, con el padding de la card. |
CardIcon · CardTitle · CardDescription | Lo básico de un body. |
CardFooter | Metadatos y estado, separados por una línea. |
Grilla
Cards link en una grilla responsive: 1, 2 y 3 columnas. Cada li es flex y
la card flex-1, así todas las de una fila miden lo mismo aunque el texto
varíe.
import { Badge } from "@/registry/ds/badge";
import { Card, CardBody, CardDescription, CardMedia, CardTitle } from "@/registry/ds/card";
const carreras = [
{ nombre: "Ingeniería en Sistemas", duracion: "5 años · Presencial", tag: "Más elegida", from: "from-primary-subtle", to: "to-info-subtle" },
{ nombre: "Ciencia de Datos", duracion: "4 años · Híbrida", tag: "Nueva", from: "from-info-subtle", to: "to-primary-faint" },
{ nombre: "Diseño Gráfico", duracion: "4 años · Presencial", tag: undefined, from: "from-warning-faint", to: "to-primary-subtle" },
{ nombre: "Economía", duracion: "4 años · Virtual", tag: "Cupos", from: "from-primary-faint", to: "to-warning-faint" },
{ nombre: "Psicología", duracion: "5 años · Presencial", tag: undefined, from: "from-info-subtle", to: "to-warning-faint" },
{ nombre: "Arquitectura", duracion: "6 años · Presencial", tag: undefined, from: "from-primary-subtle", to: "to-primary-faint" },
];
/** Grilla responsive de cards link: 1 → 2 → 3 columnas, misma altura por fila, una sola acción por card (toda la card) */
export default function CardGrid() {
return (
<ul className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-3" aria-label="Carreras">
{carreras.map((c) => (
<li key={c.nombre} className="flex">
<Card href="#" className="flex-1">
<CardMedia ratio="4/3">
<div className={`bg-linear-to-br ${c.from} ${c.to}`} />
</CardMedia>
<CardBody>
{c.tag && (
<div>
<Badge size="sm">{c.tag}</Badge>
</div>
)}
<CardTitle>{c.nombre}</CardTitle>
<CardDescription>{c.duracion}</CardDescription>
</CardBody>
</Card>
</li>
))}
</ul>
);
}Horizontal
Media a la izquierda desde 640 px y apilada en móvil: className="sm:flex-row"
en la Card y un ancho fijo en la media. Para listas de resultados con imagen.
Charla: IA en la universidad
Jue 6 nov · 18:00 · Aula Magna
Taller de escritura académica
Sáb 8 nov · 10:00 · Biblioteca
import { Chip } from "@/registry/ds/badge";
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardDescription, CardTitle } from "@/registry/ds/card";
import { Calendar } from "@/registry/ds/icons";
/** Horizontal: media a la izquierda desde 640 px, apilada en móvil. Para listas de resultados con imagen */
export default function CardHorizontal() {
return (
<div className="flex w-full max-w-xl flex-col gap-4">
{[
{ titulo: "Charla: IA en la universidad", fecha: "Jue 6 nov · 18:00 · Aula Magna", estado: "success" as const, label: "Inscripción abierta" },
{ titulo: "Taller de escritura académica", fecha: "Sáb 8 nov · 10:00 · Biblioteca", estado: "warning" as const, label: "Últimos lugares" },
].map((e) => (
<Card key={e.titulo} padding="sm" className="sm:flex-row">
<div className="h-28 shrink-0 bg-linear-to-br from-primary-subtle to-info-subtle sm:h-auto sm:w-36" aria-hidden />
<CardBody className="justify-center">
<div>
<Chip size="sm" status={e.estado}>
{e.label}
</Chip>
</div>
<CardTitle as="h4">{e.titulo}</CardTitle>
<CardDescription>
<span className="inline-flex items-center gap-1.5">
<Calendar className="size-4" />
{e.fecha}
</span>
</CardDescription>
<div className="mt-1">
<Button size="sm" variant="tonal">
Reservar lugar
</Button>
</div>
</CardBody>
</Card>
))}
</div>
);
}Seleccionable
Cards como opciones de un formulario. Adentro hay un
Radio real, así el teclado y el lector de pantalla
funcionan solos; la card marcada cambia el borde con has-data-checked.
"use client";
import { useState } from "react";
import { Badge } from "@/registry/ds/badge";
import { Card } from "@/registry/ds/card";
import { Radio, RadioGroup } from "@/registry/ds/radio";
const planes = [
{ value: "mensual", nombre: "Mensual", precio: "$ 20.500", detalle: "Se debita el 10 de cada mes.", tag: undefined },
{ value: "cuatrimestral", nombre: "Cuatrimestral", precio: "$ 98.400", detalle: "Un pago por cuatrimestre.", tag: "Ahorrás 20 %" },
{ value: "anual", nombre: "Anual", precio: "$ 172.200", detalle: "Un pago al inicio del año.", tag: "Ahorrás 30 %" },
];
/** Cards como opciones: un RadioGroup real adentro; la card marcada cambia el borde con `has-data-checked` */
export default function CardSelectable() {
const [plan, setPlan] = useState("cuatrimestral");
return (
<RadioGroup aria-label="Plan de pago" value={plan} onValueChange={(v) => setPlan(String(v))} className="w-full max-w-lg">
{planes.map((p) => (
<Card key={p.value} padding="sm" className="transition-colors has-data-checked:border-primary-accent has-data-checked:bg-primary-faint">
<Radio
value={p.value}
className="p-(--card-pad)"
label={
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{p.nombre}</span>
{p.tag && <Badge size="sm">{p.tag}</Badge>}
<span className="ml-auto font-semibold tabular-nums">{p.precio}</span>
</span>
}
supporting={p.detalle}
/>
</Card>
))}
</RadioGroup>
);
}Indicadores
KPIs: label, número grande con tabular-nums, la variación en un
Chip de estado y, si hay avance, un
Progress.
Alumnos activos
12.480
+4,2 %vs. el cuatrimestre pasado
Inscripciones hoy
318
−12 %vs. ayer
Promedio general
7,9
+0,1vs. 2025
Cupos cubiertos
82 %
import { Chip } from "@/registry/ds/badge";
import { Card, CardBody } from "@/registry/ds/card";
import { Progress } from "@/registry/ds/progress";
const stats = [
{ label: "Alumnos activos", value: "12.480", delta: "+4,2 %", estado: "success" as const, nota: "vs. el cuatrimestre pasado" },
{ label: "Inscripciones hoy", value: "318", delta: "−12 %", estado: "danger" as const, nota: "vs. ayer" },
{ label: "Promedio general", value: "7,9", delta: "+0,1", estado: "success" as const, nota: "vs. 2025" },
];
/** KPIs: label arriba, número grande, variación con Chip de estado y nota. La última muestra un avance con Progress */
export default function CardStats() {
return (
<div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Card key={s.label} padding="sm">
<CardBody className="gap-1">
<p className="text-sm text-fg-muted">{s.label}</p>
<p className="text-heading-sm font-semibold text-fg tabular-nums">{s.value}</p>
<p className="flex items-center gap-2 text-xs text-fg-subtle">
<Chip size="sm" status={s.estado}>
{s.delta}
</Chip>
{s.nota}
</p>
</CardBody>
</Card>
))}
<Card padding="sm">
<CardBody className="gap-1">
<p className="text-sm text-fg-muted">Cupos cubiertos</p>
<p className="text-heading-sm font-semibold text-fg tabular-nums">
82 <span className="text-md font-normal text-fg-subtle">%</span>
</p>
<Progress value={82} size="sm" aria-label="Cupos cubiertos: 82 %" className="mt-1" />
</CardBody>
</Card>
</div>
);
}Interactiva o no
- Toda la card es un link (
href): un solo destino, sin botones adentro. - Card con acciones: la card no es clickeable; lo son sus botones.
- Nunca las dos cosas a la vez: un link que contiene botones es inaccesible.
Medidas
| Parte | Medida |
|---|---|
| Superficie | radius-2xl 16 · borde border-sm 1 |
| Padding | md 24 (spacing-4xl) · sm 16 (spacing-2xl) |
| Body | gap spacing-md 8 |
| Icono destacado | 40 · radius-lg 10 · glifo 20 |
| Título / descripción | Text lg / Semibold · Text sm |
| Footer | padding Y 16 · borde superior |
| Media | 16:9 (también 4:3 y 1:1) |
Tokens
| Parte | Propiedad | Token |
|---|---|---|
| Superficie | Fondo | bg/elevated |
| Outline | Borde → hover | border/default |
| Elevated | Sombra → hover | Elevation/sm |
| Título | Color | text/default |
| Descripción | Color | text/muted |
| Icono destacado | Fondo | primary/subtle |
| Icono destacado | Glifo | primary/accent |
| Footer | Borde superior | border/subtle |
| Media sin cargar | Fondo | bg/surface |
| Foco (card link) | Anillo 2 px | focus/ring |
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/card.jsonPlayground
Configurá el componente y copiá el snippet: se genera del mismo estado que renderiza el preview.
Ingeniería en Sistemas
5 años · Campus Centro · Modalidad presencial y virtual.
<Card>
<CardBody>
<CardIcon>
<BookOpen />
</CardIcon>
<CardTitle>Ingeniería en Sistemas</CardTitle>
<CardDescription>5 años · Campus Centro · Modalidad presencial y virtual.</CardDescription>
</CardBody>
</Card>Props
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
Card · variant | "outline" | "elevated" | "outline" | Con borde o con sombra. |
Card · padding | "sm" | "md" | "md" | 16 o 24; lo heredan body y footer. |
Card · href | string | — | Vuelve toda la card un link, con hover y foco. |
CardMedia · ratio | "16/9" | "4/3" | "1/1" | "16/9" | Proporción de la imagen. |
CardTitle · as | "h2" | "h3" | "h4" | "h3" | Nivel de heading según la página. |
- El título es un heading real: elegí el nivel (
as) que corresponda. - Card link: un solo
<a>, un solo tab stop, foco visible en toda la superficie. El texto del link es todo el contenido: que el título vaya primero. - Las imágenes decorativas llevan
alt=""; las que informan, su descripción. - En una grilla, usá una lista (
<ul>) para que se anuncie cuántas cards hay.