Skip to content

Card

A raised surface that groups related content and actions about a single subject.

SourceRegistry
Storage
x
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"

export default function CardDemo() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader>
        <CardTitle>Storage</CardTitle>
        <CardDescription>36.2 GB of 50 GB used</CardDescription>
      </CardHeader>
      <CardContent>
        <Progress value={72} aria-label="Storage used" />
      </CardContent>
      <CardFooter className="justify-end">
        <Button size="sm">Upgrade plan</Button>
      </CardFooter>
    </Card>
  )
}

Anatomy

Team seats
x
  1. 1ContainerA raised surface with a hairline border that holds one subject. Its size sets the padding and the gaps between parts.
  2. 2FooterActions for the whole card, on a quiet band below a hairline. It stays at the bottom when cards in a row stretch to equal height.
  3. 3TitleNames the subject. A description below it adds context.
  4. 4ContentThe body — anything, padded to line up with the header and footer.
  5. 5ActionAn optional control in the header's top corner, aligned with the title.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/card.json

Usage

import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
<Card>
  <CardHeader>
    <CardTitle>Storage</CardTitle>
    <CardDescription>36.2 GB of 50 GB used</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter>…</CardFooter>
</Card>

Use the full composition. The parts share one spacing variable, so headers, content and footers line up without extra classes.

Examples

Sizes

default has 24px of padding and 20px between sections. sm tightens both to 16px and 12px for dashboards and dense grids. Every part reads the same --card-spacing variable, so you never have to override padding on individual sections.

Default
For primary content: forms, summaries, settings groups.
Small
For dense dashboards and grids of tiles.
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

export default function CardSizes() {
  return (
    <div className="grid w-full max-w-2xl items-start gap-4 sm:grid-cols-2">
      <Card>
        <CardHeader>
          <CardTitle>Default</CardTitle>
          <CardDescription>
            24px padding, 20px between sections.
          </CardDescription>
        </CardHeader>
        <CardContent className="text-muted-foreground">
          For primary content: forms, summaries, settings groups.
        </CardContent>
      </Card>
      <Card size="sm">
        <CardHeader>
          <CardTitle>Small</CardTitle>
          <CardDescription>
            16px padding, 12px between sections.
          </CardDescription>
        </CardHeader>
        <CardContent className="text-muted-foreground">
          For dense dashboards and grids of tiles.
        </CardContent>
      </Card>
    </div>
  )
}

With an action

CardAction places controls in the top-right corner of the header and keeps the title and description aligned on the left, however long they get. When space runs out, the title wraps before the action is squeezed, and footer actions wrap onto a second line, so nothing is cut off on a phone.

Team members
MC
Maya Chenmaya@northwind.dev
Owner
JO
Jonah Okaforjonah@northwind.dev
Admin
LP
Lena Parklena@northwind.dev
Member
import { MoreHorizontalIcon, UserPlusIcon } from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

const members = [
  {
    name: "Maya Chen",
    email: "maya@northwind.dev",
    role: "Owner",
    owner: true,
  },
  { name: "Jonah Okafor", email: "jonah@northwind.dev", role: "Admin" },
  { name: "Lena Park", email: "lena@northwind.dev", role: "Member" },
]

export default function CardWithAction() {
  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Team members</CardTitle>
        <CardDescription>3 of 5 seats used.</CardDescription>
        <CardAction className="flex gap-1">
          <Button size="sm" variant="outline">
            <UserPlusIcon data-icon="inline-start" />
            Invite
          </Button>
          <Button size="icon-sm" variant="ghost" aria-label="More options">
            <MoreHorizontalIcon />
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {members.map((member) => (
          <div key={member.email} className="flex items-center gap-3">
            <Avatar>
              <AvatarFallback>
                {member.name
                  .split(" ")
                  .map((part) => part[0])
                  .join("")}
              </AvatarFallback>
            </Avatar>
            <div className="flex min-w-0 flex-1 flex-col">
              <span className="truncate text-sm font-medium">
                {member.name}
              </span>
              <span className="truncate text-xs text-muted-foreground">
                {member.email}
              </span>
            </div>
            <Badge variant={member.owner ? "brand" : "secondary"}>
              {member.role}
            </Badge>
          </div>
        ))}
      </CardContent>
    </Card>
  )
}

Stat

Cards are the natural home for key figures. Keep the label small and quiet, the value large, and state the comparison period next to any change.

$48,210+12.4%
vs. last month
8,924+3.1%
vs. last month
1.8%+0.3 pt
vs. last month
import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
} from "@/components/ui/card"

const stats = [
  {
    label: "Monthly revenue",
    value: "$48,210",
    delta: "+12.4%",
    up: true,
    good: true,
  },
  {
    label: "Active users",
    value: "8,924",
    delta: "+3.1%",
    up: true,
    good: true,
  },
  {
    label: "Churn rate",
    value: "1.8%",
    delta: "+0.3 pt",
    up: true,
    good: false,
  },
]

export default function CardStat() {
  return (
    <div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
      {stats.map((stat) => {
        const Icon = stat.up ? ArrowUpRightIcon : ArrowDownRightIcon
        return (
          <Card key={stat.label} size="sm">
            <CardHeader>
              <CardDescription>{stat.label}</CardDescription>
            </CardHeader>
            <CardContent className="flex flex-col gap-1.5">
              <div className="flex items-center justify-between gap-2">
                {/* Proportional figures: tabular digits look loose at display size. */}
                <span className="text-heading-lg">{stat.value}</span>
                <Badge variant={stat.good ? "success" : "destructive"}>
                  <Icon data-icon="inline-start" />
                  {stat.delta}
                </Badge>
              </div>
              <span className="text-xs text-muted-foreground">
                vs. last month
              </span>
            </CardContent>
          </Card>
        )
      })}
    </div>
  )
}

Pricing

Team
Most popular

$24per seat / month

  • Unlimited projects
  • Up to 20 collaborators
  • Audit log and SSO
  • Priority support, 4h response
import { CheckIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

const features = [
  "Unlimited projects",
  "Up to 20 collaborators",
  "Audit log and SSO",
  "Priority support, 4h response",
]

export default function CardPricing() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader>
        <CardTitle>Team</CardTitle>
        <CardDescription>
          For product teams shipping every week.
        </CardDescription>
        <CardAction>
          <Badge variant="brand">Most popular</Badge>
        </CardAction>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <p className="flex items-baseline gap-1">
          <span className="text-display-sm">$24</span>
          <span className="text-sm text-muted-foreground">
            per seat / month
          </span>
        </p>
        <ul className="flex flex-col gap-2.5">
          {features.map((feature) => (
            <li key={feature} className="flex items-center gap-2.5 text-sm">
              <CheckIcon
                className="size-4 shrink-0 text-brand-11"
                aria-hidden="true"
              />
              {feature}
            </li>
          ))}
        </ul>
      </CardContent>
      <CardFooter>
        <Button className="w-full">Start 14-day trial</Button>
      </CardFooter>
    </Card>
  )
}

Settings

A bordered header and the tinted CardFooter strip frame a group of settings. The footer is where the card's commit action lives — separated from the controls it saves.

Notifications

Changes apply to all projects.

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"

export default function CardSettings() {
  return (
    <Card className="w-full max-w-lg">
      <CardHeader className="border-b">
        <CardTitle>Notifications</CardTitle>
        <CardDescription>Choose what we email you about.</CardDescription>
      </CardHeader>
      <CardContent>
        <FieldGroup>
          <Field orientation="horizontal">
            <FieldContent>
              <FieldLabel htmlFor="deploys">Deployments</FieldLabel>
              <FieldDescription>
                When a production deploy succeeds or fails.
              </FieldDescription>
            </FieldContent>
            <Switch id="deploys" defaultChecked />
          </Field>
          <Field orientation="horizontal">
            <FieldContent>
              <FieldLabel htmlFor="comments">Comments</FieldLabel>
              <FieldDescription>
                When someone mentions you in a review.
              </FieldDescription>
            </FieldContent>
            <Switch id="comments" defaultChecked />
          </Field>
          <Field orientation="horizontal">
            <FieldContent>
              <FieldLabel htmlFor="digest">Weekly digest</FieldLabel>
              <FieldDescription>
                A Monday summary of usage and spend.
              </FieldDescription>
            </FieldContent>
            <Switch id="digest" />
          </Field>
        </FieldGroup>
      </CardContent>
      <CardFooter className="justify-between">
        <p className="text-xs text-muted-foreground">
          Changes apply to all projects.
        </p>
        <Button size="sm">Save preferences</Button>
      </CardFooter>
    </Card>
  )
}

With media

Place an <img> as the first child and the card removes its top padding and rounds the image to its corners. Decorative covers get an empty alt.

ConferenceHybrid
Systems & Craft 2026
October 14–15, 2026Kraków and online
import { CalendarIcon, MapPinIcon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

import cover from "./assets/conference-cover.svg"

export default function CardImage() {
  return (
    <Card className="w-full max-w-sm">
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={cover.src}
        alt=""
        width={640}
        height={360}
        className="aspect-[16/9] w-full object-cover"
      />
      <CardHeader>
        <div className="flex items-center gap-2">
          <Badge variant="brand">Conference</Badge>
          <Badge variant="outline">Hybrid</Badge>
        </div>
        <CardTitle className="text-heading-md">
          Systems &amp; Craft 2026
        </CardTitle>
        <CardDescription>
          Two days on design systems, tokens and the craft of interface
          engineering.
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-2 text-sm text-muted-foreground">
        <span className="flex items-center gap-2">
          <CalendarIcon className="size-4" aria-hidden="true" />
          October 14–15, 2026
        </span>
        <span className="flex items-center gap-2">
          <MapPinIcon className="size-4" aria-hidden="true" />
          Kraków and online
        </span>
      </CardContent>
      <CardFooter className="gap-2">
        <Button className="flex-1">Get tickets</Button>
        <Button variant="outline">Agenda</Button>
      </CardFooter>
    </Card>
  )
}

Guidelines

When to use

  • To group content and actions about one subject — a project, a plan, a metric, a settings section.
  • To present a collection of peer objects in a grid, where each can be scanned and compared.

When not to use

  • To add visual separation to a single block of content on a page — use spacing or a Separator.
  • For rows of homogeneous data — use a Table or a list of Items.
  • For content that needs attention right now — use an Alert or a Dialog.

One subject, one card

Revenue
Users
DoEach card holds one subject; the page layout does the grouping.
Revenue
Don’tCards nested in cards stack borders and shadows until nothing reads as raised.

Actions

  • Put the card's primary action in the CardFooter, aligned to the end. Secondary actions go before it.
  • Put object-level actions — Edit, Share, an overflow menu — in CardAction.
  • If the whole card navigates, make the title the link and stretch its hit area over the card, instead of wrapping interactive children inside a link.

Elevation

Cards sit at the raised level: a hairline border and no shadow, in both modes. Don't add shadows to make a card stand out — emphasis comes from content and position, not depth. Floating shadows belong to popovers and dialogs.

Accessibility

  • Give it a heading when it's a section. CardTitle renders a <div> so it fits any document outline. When cards are major sections of a page, put a heading of the right level inside it — <CardTitle><h3>Storage</h3></CardTitle> — and it inherits the title style.
  • Keep reading order logical. CardAction is placed visually in the corner but comes after the title and description in the DOM, so screen readers hear what the card is before what you can do with it.
  • Media needs alternatives. Informative images need alt text; decorative ones need alt="" or aria-hidden.

API reference

Card

A <div> with the raised surface, border and shared spacing.

PropTypeDefault
size

Padding and gap between sections: 24/20px or 16/12px.

"default" | "sm""default"

CardHeader

A grid that lays out the title, description and an optional CardAction. Add className="border-b" to separate it from the content; its bottom padding follows automatically.

CardTitle

The card's title, set in heading-sm (heading-xs in small cards).

CardDescription

Supporting text under the title in muted-foreground.

CardAction

Controls pinned to the top-right corner of the header, spanning the title and description rows.

CardContent

The main content, padded horizontally to match the header.

CardFooter

A tinted strip separated by a border, for the card's actions and status. It removes the card's bottom padding so the tint reaches the edge.