Card
Contenedor de contenido — el mismo hairline de Attachment y Alert en vez de sombra: aquí la separación de la página viene del borde, no de un box-shadow flotando encima de un fondo ya oscuro.
Playground
Obsidian
Plantilla premium para estudios creativos
Doce secciones, modo oscuro nativo, tokens listos para adaptar a tu marca.
<Card className="w-[320px]">
<CardHeader>
<CardTitle>Obsidian</CardTitle>
<CardDescription>Plantilla premium para estudios creativos</CardDescription>
<CardAction>
<Badge variant="primary">Nuevo</Badge>
</CardAction>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Doce secciones, modo oscuro nativo, tokens listos para adaptar a tu marca.
</p>
</CardContent>
<CardFooter className="justify-between border-t border-border pt-6">
<span className="text-sm font-medium">$68</span>
<Button size="sm" variant="outline">Ver detalles</Button>
</CardFooter>
</Card>Con imagen
Sin CardImage propio — es un <img> normal como primer hijo, a sangre. overflow-hidden no viene por defecto en Card (un card genérico puede contener un menú o un Calendar que necesita salirse de sus límites), así que aquí se agrega vía className solo en el card que lo necesita.
Obsidian
Plantilla premium para estudios creativos
<Card className="w-[280px] overflow-hidden">
{/* overflow-hidden es opt-in aquí, no viene por defecto en Card — un
card genérico puede contener un dropdown o un Calendar que
necesita salirse de sus límites */}
<img
src="/plantilla-preview.jpg"
alt="Vista previa de la plantilla Obsidian"
className="aspect-video w-full object-cover"
/>
<CardHeader>
<CardTitle>Obsidian</CardTitle>
<CardDescription>Plantilla premium para estudios creativos</CardDescription>
</CardHeader>
<CardFooter className="justify-between border-t border-border pt-6">
<span className="text-sm font-medium">$68</span>
<Button size="sm" variant="outline">Ver detalles</Button>
</CardFooter>
</Card>Interactivo
interactive es para cuando el card entero es el disparador — navegar a un detalle, abrir algo — no una decoración de hover porque sí. El borde pasa a dorado tenue, el fondo se tiñe apenas y dos marcas de esquina opuestas cierran como un visor — el mismo gesto que ya usan las cards de la portada de /components. Esquinas opuestas, no las cuatro: se lee como un solo movimiento, no como decoración.
Estático
Un contenedor, sin gesto de hover
Para agrupar contenido que no navega a ningún lado.
Interactivo
Todo el card es el trigger
Prueba el hover — hairline, fondo y las esquinas.
Con CardAction
Un elemento aparte del título/descripción, anclado arriba a la derecha del header — un badge de estado, un botón de menú. El header detecta su presencia solo (vía :has()) y pasa de una columna a dos; sin CardAction, el título sigue ocupando el ancho completo.
Obsidian
Plantilla premium para estudios creativos
Doce secciones, modo oscuro nativo, tokens listos para adaptar a tu marca.
<CardHeader>
<CardTitle>Obsidian</CardTitle>
<CardDescription>Plantilla premium para estudios creativos</CardDescription>
<CardAction>
<Badge variant="primary">Nuevo</Badge>
</CardAction>
</CardHeader>
{/* con CardAction presente, el header pasa solo de una columna a dos —
grid-cols-[1fr_auto] vía has(), nada que calcular a mano */}Código
import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
/** hover con hairline dorado + fondo apenas tintado + dos marcas de
* esquina que cierran como un visor — mismo gesto que las cards de
* /components. Para un card que es en sí mismo el disparador de una
* acción (navegar, abrir), no decoración */
interactive?: boolean;
}
// dos esquinas opuestas, no las cuatro — se lee como un solo gesto
// deliberado, no como decoración. En reposo quedan corridas 5px hacia
// el centro y transparentes; el hover las devuelve a su esquina real.
const cornerBase = cn(
"pointer-events-none absolute h-3.5 w-3.5 border-primary opacity-0 transition-[opacity,transform] duration-300",
EASE,
"group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100",
);
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, interactive, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex flex-col rounded-md border border-border bg-background",
interactive &&
cn("group relative cursor-pointer transition-colors duration-200 hover:border-primary/40 hover:bg-muted/40", EASE),
className,
)}
{...props}
>
{interactive && (
<>
<span aria-hidden="true" className={cn(cornerBase, "left-2 top-2 translate-x-[5px] translate-y-[5px] border-l border-t")} />
<span
aria-hidden="true"
className={cn(cornerBase, "bottom-2 right-2 -translate-x-[5px] -translate-y-[5px] border-b border-r")}
/>
</>
)}
{children}
</div>
),
);
Card.displayName = "Card";
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="card-header"
className={cn(
// grid, no flex: cuando aparece un CardAction a la derecha, la
// segunda columna nace sola (auto) — no hay que calcular nada
"grid auto-rows-min grid-cols-[1fr] items-start gap-1.5 p-6 has-[[data-slot=card-action]]:grid-cols-[1fr_auto]",
className,
)}
{...props}
/>
),
);
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-base font-medium leading-none", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
export const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
export const CardAction = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start", className)} {...props} />
),
);
CardAction.displayName = "CardAction";
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center gap-2 p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";Código real de packages/ui/src/card.tsx. El hairline entre CardFooter y el resto (como en el ejemplo de arriba) es opt-in vía className="border-t border-border" — no todos los cards lo necesitan.
Uso
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "./components/ui/card";
<Card>
<CardHeader>
<CardTitle>Obsidian</CardTitle>
<CardDescription>Plantilla premium para estudios creativos</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>...</CardFooter>
</Card>API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Card | interactive | boolean | false |
| Header / Title / Description / Action / Content / Footer | — | atributos nativos de su elemento | |