For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Card

The Card component from this design system, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/card

Options

  • variant="muted" removes the border and shadow and uses a muted surface.
  • variant="flat" keeps the card surface but removes the border and shadow.
  • interactive adds a 2px ring on hover (the --border-hover token) and on keyboard focus. The ring is a box-shadow, so nothing lifts, scales or shifts.
  • render changes the element, for example <Card render={<section />} /> or <CardTitle render={<h3 />} />.
  • variant="sunken" keeps the default border and shadow and uses the --surface-sunken fill, for panels nested in a surface. muted and flat are unchanged.
  • variant="outline" keeps the default border and shadow with the page fill (--background), which is darker than --card in dark mode.
  • variant="raised" is a borderless object with the card shadow and the --surface-nested fill, for a tile placed on a muted or sunken surface.
  • size="xs" sets --card-spacing to 10px, for small tiles. On a muted card the header and footer stay 16px from the edge so a nested tile can sit closer to it.
  • CardTitle size="lg" is a big number: text-2xl, medium weight, tabular figures.
  • size="flush" sets --card-spacing to 0 for content that brings its own padding.
  • radius sets the corner radius in px (10, 12, 14, 16, 18, 20, 24). Header, footer and edge images follow it. The default is 12.
  • CardTitle variant="secondary" is a small secondary heading: text-sm, medium weight, muted colour.

Source

import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import * as React from "react";

const CARD_RADIUS = {
  10: "[--card-radius:10px]",
  12: "[--card-radius:12px]",
  14: "[--card-radius:14px]",
  16: "[--card-radius:16px]",
  18: "[--card-radius:18px]",
  20: "[--card-radius:20px]",
  24: "[--card-radius:24px]",
} as const;

function Card({
  className,
  size = "default",
  variant = "default",
  radius = 12,
  interactive = false,
  render,
  ...props
}: useRender.ComponentProps<"div"> & {
  /**
   * `flush` removes the card spacing, for content that brings its own padding.
   * `xs` is 10px, for small tiles. On a `muted` card the header and footer
   * stay 16px from the edge, so a nested tile can sit closer to it.
   */
  size?: "default" | "sm" | "xs" | "flush";
  /**
   * `sunken` keeps the default border and shadow with a recessed
   * fill (--surface-sunken), for panels nested in a surface.
   * `outline` keeps them with the page fill (--background), which is
   * darker than --card in dark mode.
   * `raised` is a borderless object with the card shadow, for a tile placed
   * on a `muted` or `sunken` surface.
   */
  variant?:
    | "default"
    | "muted"
    | "flat"
    | "sunken"
    | "outline"
    | "bordered"
    | "raised";
  /** Corner radius in px. Header, footer and images follow it. */
  radius?: 10 | 12 | 14 | 16 | 18 | 20 | 24;
  /** Adds a 2px hover and focus ring, with no lift, scale or layout shift. */
  interactive?: boolean;
}) {
  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        className: cn(
          "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-(--card-radius) py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-[>img:first-child]:pt-0 data-[size=flush]:[--card-spacing:0px] data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=xs]:[--card-spacing:--spacing(2.5)] *:[img:first-child]:rounded-t-(--card-radius) *:[img:last-child]:rounded-b-(--card-radius)",
          CARD_RADIUS[radius],
          (variant === "default" ||
            variant === "sunken" ||
            variant === "outline") &&
            "border border-[var(--border-subtle,var(--border))] bg-card shadow-[0_1px_1px_0_rgb(0_0_0/0.05)] dark:shadow-[0_1px_1px_0_rgb(0_0_0/0.14)]",
          // In light, --surface-sunken and the ghost hover fill are the same
          // grey, so children of a sunken card hover on the next step. Dark
          // keeps its own hover fill.
          variant === "sunken" &&
            "bg-[var(--surface-sunken,var(--muted))] not-dark:[--surface-hover:var(--surface-strong,var(--border))]",
          variant === "outline" && "bg-background",
          // The full --border line, brighter than --border-subtle in dark.
          variant === "bordered" &&
            "border border-border bg-card shadow-[0_1px_1px_0_rgb(0_0_0/0.05)] dark:shadow-[0_1px_1px_0_rgb(0_0_0/0.14)]",
          variant === "muted" &&
            "bg-[var(--surface-sunken,var(--muted))] not-dark:[--surface-hover:var(--surface-strong,var(--border))]",
          variant === "flat" && "border-0 bg-card shadow-none",
          variant === "raised" &&
            "border-0 bg-[var(--surface-nested,var(--card))] shadow-[0_1px_1px_0_rgb(0_0_0/0.05)] not-dark:[--surface-hover:var(--muted)] dark:shadow-[0_1px_1px_0_rgb(0_0_0/0.14)]",
          interactive &&
            "cursor-pointer ring-2 ring-transparent transition-[box-shadow] duration-150 ease-out outline-none hover:ring-[var(--border-hover,var(--border))] focus-visible:ring-ring data-popup-open:ring-ring data-popup-open:hover:ring-ring motion-reduce:transition-none",
          className
        ),
      },
      props
    ),
    render,
    state: { interactive, slot: "card", size, variant },
  });
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn(
        "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-(--card-radius,12px) px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=xs]/card:group-data-[variant=muted]/card:px-4 group-data-[size=xs]/card:group-data-[variant=muted]/card:pt-1.5 [.border-b]:pb-(--card-spacing)",
        className
      )}
      {...props}
    />
  );
}

function CardTitle({
  className,
  variant = "default",
  size = "default",
  render,
  ...props
}: useRender.ComponentProps<"div"> & {
  /**
   * `secondary` is a small secondary heading: text-sm, medium, muted colour.
   * `lg` is a big number.
   */
  variant?: "default" | "secondary";
  size?: "default" | "lg";
}) {
  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        className: cn(
          "text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
          variant === "secondary" &&
            "text-sm leading-5 font-medium text-muted-foreground group-data-[size=sm]/card:text-sm",
          size === "lg" &&
            "text-2xl leading-8.5 font-medium tabular-nums group-data-[size=sm]/card:text-2xl",
          className
        ),
      },
      props
    ),
    render,
    state: { slot: "card-title" },
  });
}

function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

function CardAction({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-action"
      className={cn(
        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
        className
      )}
      {...props}
    />
  );
}

function CardContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-content"
      className={cn("flex flex-col gap-3 px-(--card-spacing)", className)}
      {...props}
    />
  );
}

function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-footer"
      className={cn(
        "flex items-center rounded-b-(--card-radius,12px) px-(--card-spacing) group-data-[size=xs]/card:group-data-[variant=muted]/card:px-4 group-data-[size=xs]/card:group-data-[variant=muted]/card:pb-1.5 [.border-t]:pt-(--card-spacing)",
        className
      )}
      {...props}
    />
  );
}

export {
  Card,
  CardHeader,
  CardFooter,
  CardTitle,
  CardAction,
  CardDescription,
  CardContent,
};