Card
The framed card: a title strip on the frame and the content on an inset sheet.
Vibra's card is a frame — the surface plane and a hairline ring — holding a 52px title strip, with CardContent as an inset sheet on the card plane with a ring of its own, so the title sits on the frame and the figures sit a step above it; CardFooter is a second strip under the sheet. shadcn's is one white box with the title inside it. Padding reads --density-card, and variant="interactive" lifts the ring and adds the float shadow on hover for a card that is itself a link.
Install
npx shadcn@latest add @vibra/cardNeeds the @vibra registry in your components.json — set it up once.
Examples
import { ArrowUpRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function CardDemo() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Monthly recurring revenue</CardTitle>
<CardDescription>September, all plans</CardDescription>
<CardAction>
<Button variant="ghost" size="icon-xs" aria-label="Open the revenue report">
<ArrowUpRightIcon aria-hidden="true" />
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-1">
<span className="type-numeral text-3xl">$48,290</span>
<span className="text-sm text-muted-foreground">Up 6.2% on August, from 1,204 paying workspaces.</span>
</CardContent>
<CardFooter className="text-xs text-muted-foreground">Updated 4 minutes ago</CardFooter>
</Card>
)
}With a menu
The card's own verbs behind one button in the strip, named for the card it belongs to.
import { DownloadIcon, EllipsisIcon, PinIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const PLANS = [
{ plan: "Team", mrr: "$28,400" },
{ plan: "Business", mrr: "$14,650" },
{ plan: "Starter", mrr: "$5,240" },
]
// The card's own verbs behind one button in the strip. The button is named for
// the card, so on a dashboard of twelve it is clear whose menu it opens.
export default function CardMenu() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Revenue by plan</CardTitle>
<CardDescription>September, recurring</CardDescription>
<CardAction>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Options for Revenue by plan" />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>
<DownloadIcon aria-hidden="true" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem>
<PinIcon aria-hidden="true" />
Pin to Overview
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Remove from dashboard
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardAction>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-2 text-sm">
{PLANS.map((row) => (
<div key={row.plan} className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground">{row.plan}</dt>
<dd className="font-medium tabular-nums">{row.mrr}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)
}As a link
variant="interactive" with the title's link stretched over the card: one target, one tab stop, a ring on the frame.
import { ArrowUpRightIcon } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
const REPORTS = [
{ href: "/reports/q3-board", title: "Q3 board report", note: "Updated 2 hours ago", summary: "For the October 7 board meeting." },
{ href: "/reports/churn-cohorts", title: "Churn by cohort", note: "Updated yesterday", summary: "Monthly cohorts since January." },
]
// A card that is a link: variant="interactive" lifts it on hover, and the
// link is the title's — stretched over the card, so the whole card is one
// target and one tab stop, and its name is the title rather than every word
// inside. The focus ring lands on the frame, where the clip can't cut it.
export default function CardLink() {
return (
<ul className="grid w-full max-w-xl gap-3 sm:grid-cols-2">
{REPORTS.map((report) => (
<li key={report.href} className="flex">
<Card
variant="interactive"
className="relative w-full has-[a:focus-visible]:focus-outline"
>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<a href={report.href} className="outline-none after:absolute after:inset-0">
{report.title}
</a>
<ArrowUpRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
</CardTitle>
<CardDescription>{report.note}</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground">{report.summary}</CardContent>
</Card>
</li>
))}
</ul>
)
}Sizes
The same card at default and sm: a 44px strip and 12px padding for a side panel or a dense row.
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const RENEWALS = [
{ customer: "Blue Harbor Logistics", date: "Sep 26" },
{ customer: "Kestrel Analytics", date: "Sep 28" },
]
// The same card at both sizes. sm takes the strip from 52px to 44px and the
// padding from 16px to 12px — for a side panel, or a dense row of cards.
export default function CardSizes() {
return (
<div className="grid w-full max-w-xl items-start gap-3 sm:grid-cols-2">
{(["default", "sm"] as const).map((size) => (
<Card key={size} size={size}>
<CardHeader>
<CardTitle>
Renewals this week <span className="font-mono text-xs font-normal text-muted-foreground">· {size}</span>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-1.5 text-sm">
{RENEWALS.map((renewal) => (
<li key={renewal.customer} className="flex justify-between gap-3">
<span className="truncate">{renewal.customer}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{renewal.date}</span>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
)
}With a form
A field on the sheet, and the footer strip holding what happened and the one action.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// A setting in a card: the field on the sheet, and the footer strip holding
// what happened on the left and the one action on the right. The status line
// is there from the start, so saving is read out where it is shown.
export default function CardForm() {
const id = React.useId()
const [saved, setSaved] = React.useState(false)
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Billing contact</CardTitle>
<CardDescription>Where invoices and receipts go</CardDescription>
</CardHeader>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault()
setSaved(true)
}}
>
<CardContent>
<Field>
<FieldLabel htmlFor={`${id}-email`}>Email</FieldLabel>
<Input id={`${id}-email`} type="email" defaultValue="finance@northwind.example" onChange={() => setSaved(false)} />
<FieldDescription>Invoices go out on the 1st of each month.</FieldDescription>
</Field>
</CardContent>
<CardFooter className="justify-between">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{saved ? "Saved." : null}
</p>
<Button type="submit" size="sm">
Save
</Button>
</CardFooter>
</form>
</Card>
)
}Holding a table
The content passes p-0 so the table takes the sheet's whole width; a column steps aside on a phone.
import { getInitials } from "@/lib/format"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const ACCOUNTS = [
{ owner: "Maren Kovač", customer: "Blue Harbor Logistics", plan: "Business", mrr: "$4,820" },
{ owner: "Hiro Tanaka", customer: "Kestrel Analytics", plan: "Team", mrr: "$2,310" },
{ owner: "Saoirse Byrne", customer: "Meridian Foods", plan: "Team", mrr: "$1,960" },
]
// A table wants the sheet's whole width, so the content passes p-0 and the
// table's own padding takes over. The plan column steps aside on a phone
// rather than pushing the table sideways.
export default function CardTable() {
return (
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Largest accounts</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead className="max-sm:hidden">Plan</TableHead>
<TableHead className="text-right">MRR</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ACCOUNTS.map((account) => (
<TableRow key={account.customer}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(account.owner)} alt="" />
<AvatarFallback>{getInitials(account.owner)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{account.customer}</p>
<p className="truncate text-xs text-muted-foreground">{account.owner}</p>
</div>
</div>
</TableCell>
<TableCell className="text-muted-foreground max-sm:hidden">{account.plan}</TableCell>
<TableCell className="text-right font-medium">{account.mrr}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}With tabs
Tabs in the strip and their panels on the sheet; an empty tab says so where its rows would be.
"use client"
import { InboxIcon } from "lucide-react"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const OVERDUE = [
{ id: "INV-2041", customer: "Blue Harbor Logistics", amount: "$1,240.00", late: "12 days" },
{ id: "INV-2036", customer: "Kestrel Analytics", amount: "$860.00", late: "5 days" },
{ id: "INV-2029", customer: "Meridian Foods", amount: "$2,115.00", late: "2 days" },
]
// Tabs in the strip, their panels on the sheet: one Tabs root wraps the card
// so the list and the panels stay tied however far apart they sit. A tab with
// nothing in it says so in the sheet, where its rows would have been.
export default function CardTabs() {
return (
<Tabs defaultValue="overdue" className="w-full max-w-md">
<Card>
<CardHeader>
<CardTitle>Collections</CardTitle>
<CardDescription>Invoices waiting on a customer</CardDescription>
<CardAction>
<TabsList>
<TabsTrigger value="overdue">Overdue · 3</TabsTrigger>
<TabsTrigger value="disputed">Disputed · 0</TabsTrigger>
</TabsList>
</CardAction>
</CardHeader>
<CardContent className="py-2">
<TabsContent value="overdue">
<ul className="divide-y">
{OVERDUE.map((invoice) => (
<li key={invoice.id} className="flex items-baseline justify-between gap-3 py-2 text-sm">
<span className="min-w-0 truncate">
<span className="font-mono text-xs">{invoice.id}</span> · {invoice.customer}
</span>
<span className="shrink-0 tabular-nums">
{invoice.amount} <span className="text-muted-foreground">· {invoice.late}</span>
</span>
</li>
))}
</ul>
</TabsContent>
<TabsContent value="disputed">
<EmptyState size="sm" icon={<InboxIcon aria-hidden="true" />} title="No disputes" description="A disputed invoice shows up here until it's resolved." />
</TabsContent>
</CardContent>
</Card>
</Tabs>
)
}Loading
The title stays while skeletons hold the rows' shape; the sheet is busy and says so in words.
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
// While the rows are on their way the card keeps its title — the reader knows
// what is coming — and the sheet holds skeletons of the rows' shape. The sheet
// is busy and says so in words; the skeletons stop pulsing for reduced motion.
export default function CardLoading() {
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Renewals this month</CardTitle>
<CardDescription>September</CardDescription>
</CardHeader>
<CardContent aria-busy="true" className="flex flex-col gap-3">
<span role="status" className="sr-only">
Loading renewals…
</span>
{[0, 1, 2].map((row) => (
<div key={row} aria-hidden="true" className="flex items-center gap-3">
<Skeleton className="size-7 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-2.5 w-1/3" />
</div>
<Skeleton className="h-3 w-12" />
</div>
))}
</CardContent>
</Card>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "default" | "sm" | "default" | sm tightens the strip to 44px and the padding to 12px. |
| variant | "default" | "interactive" | "default" | interactive is for a card that is a link or a button: the ring lifts, the frame floats, and it presses a pixel down. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
/**
* The framed card. The root is the `frame` recipe — the surface plane and a
* hairline ring — and it holds a strip of header, a gap of four pixels, and
* the content as an inset sheet on the card plane with a ring of its own, so
* a title sits on the frame and the numbers, the chart or the table sit on
* white a step above it. A footer is a second strip under the sheet. A page
* that hand-rolls either recipe is drawing a fifth kind of box; there is a
* test that says so.
*
* Padding comes from `--density-card` so a compact dashboard tightens its cards
* along with its tables, with the comfortable literal as the fallback for an
* install that took the component without the density stylesheet.
*/
function Card({
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<"div"> & {
size?: "default" | "sm"
/**
* `interactive` is for a card that is itself a link or a button: the ring
* lifts to `--rule` and the frame takes the floating shadow on hover, and
* the whole card presses a pixel down. The property list is explicit —
* `transition-all` would animate the layout too.
*/
variant?: "default" | "interactive"
}) {
return (
<div
data-slot="card"
data-size={size}
data-variant={variant}
className={cn(
"group/card flex flex-col gap-1 overflow-hidden frame text-sm text-card-foreground [--card-spacing:var(--density-card,1rem)] data-[size=sm]:[--card-spacing:--spacing(3)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
variant === "interactive" &&
"transition-[box-shadow,background-color,translate] duration-(--duration-base) ease-(--ease-standard) hover:ring-(--rule) hover:shadow-(--shadow-float) active:translate-y-px",
className
)}
{...props}
/>
)
}
// The strip: 52px tall with the title centred in it, so a card with an action
// and a card without one carry their titles on the same line. A description
// adds a second row and the strip grows. The strip is a container of its own:
// under 12rem of content width — a tile in a four-up divided group, a KPI on
// a phone — the action drops under the title instead of being clipped off the
// right edge. A four-up row on a 1,440px page is 238px of content and keeps
// its action on the title line.
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid min-h-13 auto-rows-min content-center items-center gap-1 px-(--card-spacing) py-2.5 has-data-[slot=card-action]:grid-cols-[minmax(0,1fr)_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] group-data-[size=sm]/card:min-h-11 group-data-[size=sm]/card:py-2 [.border-b]:pb-2.5 @max-[12rem]/card-header:has-data-[slot=card-action]:grid-cols-1",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-sm font-semibold", className)}
{...props}
/>
)
}
// The title's second line: body size, muted.
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
// The action shares the title's row and centre line. With a description it
// spans both lines and centres against the pair; spanning two rows without
// one made an empty second row, which lifted the title above the action's
// centre (2px for a pill, 6px for a 32px tab list).
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-start-1 self-center justify-self-end group-has-data-[slot=card-description]/card-header:row-end-3 @max-[12rem]/card-header:col-start-1 @max-[12rem]/card-header:row-span-1 @max-[12rem]/card-header:row-start-2 @max-[12rem]/card-header:justify-self-start",
className
)}
{...props}
/>
)
}
// The inset sheet: the card plane with its own hairline, a radius one step
// inside the frame's, and the density padding. `flex-1` so it takes the slack
// in a row of unequal cards and their footers land on one line. A table or a
// chart that wants the sheet's full width passes `p-0`.
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn(
"flex-1 overflow-hidden rounded-lg bg-card p-(--card-spacing) ring-1 ring-border",
className
)}
{...props}
/>
)
}
// A second strip on the frame, under the sheet. `mt-auto` so a card without
// content still pins it to the bottom.
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("mt-auto flex items-center gap-2 px-(--card-spacing) py-2.5", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}