← Componentes

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

Interactivo

Obsidian

Plantilla premium para estudios creativos

Nuevo

Doce secciones, modo oscuro nativo, tokens listos para adaptar a tu marca.

$68
<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.

Vista previa de la plantilla Obsidian

Obsidian

Plantilla premium para estudios creativos

$68
<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

Nuevo

Doce secciones, modo oscuro nativo, tokens listos para adaptar a tu marca.

$68
<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

ComponentePropTipoDefault
Cardinteractivebooleanfalse
Header / Title / Description / Action / Content / Footeratributos nativos de su elemento