Card
A container with header, content, and footer sections. Includes dashboard and metric card variants.
Installation
Usage
import {
Card, CardHeader, CardTitle, CardDescription, CardAction,
CardContent, CardFooter, CardRows, CardRow, CardTableHeader,
CardTableBody, DashboardCardHeader, MetricCardValue,
} from "@/components/ui/card"Examples
Default
Choose which notifications you'd like to receive and how they're delivered.
With Header Border
Add border-b to CardHeader to separate the header from content.
Choose which notifications you'd like to receive and how they're delivered.
With Footer Border
Add border-t and pt-4 to CardFooter to close the card off with a divider. The line sits 20px below the content (CardContent's pb-5) and 16px above the actions, which keep the footer's default 20px to the bottom edge - the exact inverse of the header divider's rhythm.
Choose which notifications you'd like to receive and how they're delivered.
With Form
Card wrapping form inputs: shows Card, Input, and Button working together.
With Action
CardAction places a control in the top-right of the header.
Jane Doe
jane@example.com
Alex Smith
alex@example.com
With Rows
CardRow creates a bordered list layout for settings or navigation.
Notifications
Push and email alerts
Security
Password and 2FA
Language
English (US)
Compact Header
DashboardCardHeader + MetricCardValue for data display cards.
Table Header
CardTableHeader adds column labels that align with grid rows below.
Expandable
Reveal detail rows with a smooth disclosure. The summary stays put and the toggle lives in the header (neither moves), while the rows expand via Collapsible - a measured height animation that eases open and closed. The card's height just follows the Collapsible, so there's no snap or layout shift.
Tabbed Filter
The canonical pattern for a card with tabbed navigation. Tab (or segmented) navigation never goes inside DashboardCardHeader - its h-12 band is sized for a single 32px icon action, so a control cluster crammed in there sits cramped and off-center. Instead the header carries the title + one action, the tabs get their own region below it (the default py-5 gives 20px above and below the pill), and a border-t closes the filter off from the list it filters.
Dotgrid
Set variant="dotgrid" to fill the card with a faint dot-grid canvas. The dots paint behind the content, so children sit on top of the staging surface. Used for positioning and placement demos.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variantCard | "default" | "dotgrid" | "default" | Visual treatment. dotgrid fills the card with a faint dot-grid canvas that paints behind the content, a staging surface for positioning and placement demos. |
| animateHeightCard | boolean | false | Smoothly animates the card's own height when its content size changes. Opt-in; leave off when a child already animates its own height (e.g. an inner Collapsible). |
| title*DashboardCardHeader | string | - | Header label, truncated when it overflows. |
| actionDashboardCardHeader | React.ReactNode | - | Optional control rendered on the right side of the header. |
| linkDashboardCardHeader | { label: string; onClick: () => void } | - | Subtle text-button link on the right of the header; ignored when action is set. |
| value*MetricCardValue | React.ReactNode | - | The large metric value. |
| goal*MetricCardValue | React.ReactNode | - | Goal line rendered under the value. |
| unitMetricCardValue | string | - | Unit suffix appended to the goal line. |
| deltaMetricCardValue | { value: number; label?: string } | - | Progress remainder line: positive values render label (default {value} to go), zero or below renders Goal reached!. |
| childrenMetricCardValue | React.ReactNode | - | Optional inline controls rendered beside the value (e.g. +/- buttons). |
| columns*CardTableHeader | string[] | - | Column labels for the header row. |
| gridClassName*CardTableHeader | string | - | Grid classes matching the body rows, e.g. grid-cols-[10rem_1fr_5rem]. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { AnimatedCardShell } from "@/registry/ui/card-animate-height"
// The edge is `shadow-ring-md` (an OUTER box-shadow hairline), not a border.
// An overflow-hidden or flush-edge ancestor will clip it: give a Card perimeter
// room, and counter with negative margin if you need flush alignment.
const cardBase =
"bg-card text-foreground flex flex-col rounded-lg shadow-ring-md overflow-hidden"
function Card({
variant = "default",
animateHeight,
className,
children,
...props
}: React.ComponentProps<"div"> & {
/** Visual treatment. `dotgrid` fills the card with a faint dot-grid canvas -
* a staging surface for positioning/placement demos. */
variant?: "default" | "dotgrid"
/** Smoothly animate the card's own height when its content size changes
* (a child grows/shrinks/swaps). Opt-in - leave off when a child already
* animates its own height (e.g. an inner Collapsible). Pulls in a small
* client shell only when enabled; the default Card stays server-rendered. */
animateHeight?: boolean
}) {
// Dots paint as the root's background-image, so they always sit behind the
// card's content (no overlay layer that could cover controls).
const dotgrid = variant === "dotgrid"
const classes = cn(
cardBase,
dotgrid &&
"bg-[radial-gradient(color-mix(in_oklab,var(--color-neutral-500)_15%,transparent)_1px,transparent_1px)] [background-size:12px_12px]",
className
)
if (animateHeight) {
return <AnimatedCardShell className={classes} {...props}>{children}</AnimatedCardShell>
}
return (
<div data-slot="card" className={classes} {...props}>
{children}
</div>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1 px-4 pt-5 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
// leading-snug, not leading-none: a title that wraps to two lines would
// collide ascenders into descenders at 1.0. text-balance evens the
// wrapped lines rather than leaving one orphaned word.
className={cn("leading-snug font-semibold text-balance", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm text-pretty", 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"
// Directly after a BORDERLESS header, close the title-to-content gap to
// 12px so the proximity gradient reads 20 (edge) > 12 (header) > 4
// (title/description) and the title binds to its body. Standalone
// content keeps py-5.
// The `:not(.border-b)` scope matters: after the bordered-header recipe
// the rule already separates the regions, so the content region reopens
// at the full 20px rather than hugging the line tighter than the title.
className={cn(
"px-4 py-5 [[data-slot=card-header]:not(.border-b)+&]:pt-3",
className
)}
{...props}
/>
)
}
/** Body region for table/row-list cards (typically paired with CardTableHeader).
* Its direct children are self-padded rows (own `py` + dividers), so the body
* itself carries only a minimal vertical inset to buffer the first/last row
* from the header and bottom edge - never the default content padding. */
function CardTableBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-table-body"
className={cn("px-4 py-1", className)}
{...props}
/>
)
}
/** Groups CardRow children. Card is structure-only, so rows sit flush to the
* bottom edge with no negative-margin hack. */
function CardRows({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-rows"
className={className}
{...props}
/>
)
}
/** Display row - no hover by default (parity with TableRow: hover implies
* interactivity). Clickable consumers opt in via `className`. */
function CardRow({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-row"
className={cn(
"flex items-center gap-4 px-4 py-4 border-b border-border last:border-0",
className
)}
{...props}
/>
)
}
/** Compact card header used across dashboard cards: 40px height, title left, optional action right */
function DashboardCardHeader({
title,
action,
link,
className,
...props
}: {
title: string
action?: React.ReactNode
/** Standard header link - renders as subtle text button on the right */
link?: { label: string; onClick: () => void }
} & Omit<React.ComponentProps<"div">, "children">) {
const rightContent = action ? (
// Icon-button actions (Kebab, icon Button) carry ~6px of chrome between
// glyph and box edge; the outdent seats the GLYPH on the px-4 content
// inset so it optically aligns with the title opposite it.
<span className="-mr-1.5 flex items-center">{action}</span>
) : link ? (
<button
onClick={link.onClick}
className="rounded-sm text-xs text-muted-foreground hover:text-foreground transition-colors duration-150 cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring-accent pointer-coarse:py-2"
>
{link.label}
</button>
) : null
return (
<div
data-slot="dashboard-card-header"
// h-12: a 32px icon action + 8px breathing each side; 40px left the
// action's hover box 4px off both borders.
className={cn(
"flex items-center h-12 px-4 border-b border-border bg-muted",
rightContent && "justify-between",
className
)}
{...props}
>
<span className="text-sm leading-normal font-medium truncate min-w-0">{title}</span>
{rightContent}
</div>
)
}
/** Metric value block for dashboard metric cards: large value, goal line, "to go" line */
function MetricCardValue({
value,
goal,
delta,
unit,
children,
className,
}: {
value: React.ReactNode
goal: React.ReactNode
delta?: { value: number; label?: string }
unit?: string
/** Optional inline controls (e.g. weight +/- buttons) */
children?: React.ReactNode
} & Pick<React.ComponentProps<"div">, "className">) {
return (
<div className={cn("flex flex-col gap-0.5", className)}>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold">{value}</span>
{children}
</div>
<span className="text-xs text-muted-foreground">{goal}{unit && ` ${unit}`}</span>
{delta !== undefined && delta.value > 0 && (
<span className="text-xs text-subtle-foreground">{delta.label ?? `${delta.value} to go`}</span>
)}
{delta !== undefined && delta.value <= 0 && (
<span className="text-xs text-subtle-foreground">Goal reached!</span>
)}
</div>
)
}
/** Table-style column header row for data cards. Pass the same grid classes as the body rows. */
function CardTableHeader({
columns,
gridClassName,
className,
}: {
columns: string[]
/** Grid classes matching the body rows, e.g. "grid-cols-[10rem_1fr_5rem]" */
gridClassName: string
className?: string
}) {
return (
<div
data-slot="card-table-header"
className={cn(
"hidden md:grid items-center gap-8 px-4 py-3 border-b border-border bg-muted text-xs font-medium text-muted-foreground",
gridClassName,
className
)}
>
{columns.map((col, i) => (
<span key={`${col}-${i}`}>{col}</span>
))}
</div>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-4 pb-5", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
CardTableBody,
CardRows,
CardRow,
CardTableHeader,
DashboardCardHeader,
MetricCardValue,
}"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
/** True when a descendant is currently animating its OWN height (e.g. an inner
* Collapsible's disclosure height transition). We scope the query to `inner` so the
* card's own height transition - which lives on the OUTER element, outside
* `inner`'s subtree - never counts as a child animation (that would make the
* card yield to itself and kill the snap animation). We match on the animated
* property, so unrelated motion (a spinner, the trigger's chevron rotate)
* doesn't suppress height animation. */
function isChildAnimatingHeight(inner: HTMLElement) {
if (typeof inner.getAnimations !== "function") return false
return inner.getAnimations({ subtree: true }).some((a) => {
// "pending" is a real play state browsers report but the DOM lib type omits.
const state = a.playState as string
if (state !== "running" && state !== "pending") return false
const transitionProperty = (a as CSSTransition).transitionProperty
if (transitionProperty) {
return transitionProperty === "height" || transitionProperty === "all"
}
try {
const effect = a.effect as KeyframeEffect | null
return effect?.getKeyframes().some((k) => "height" in k) ?? false
} catch {
return false
}
})
}
/**
* Card shell that smoothly animates its OWN height when content size changes.
* Opt-in via `<Card animateHeight>`.
*
* Structure: the height-controlled box is the OUTER element; the children live
* in an INNER wrapper whose height is never touched, so its measured height is
* always the true content height (reading `scrollHeight`/`clientHeight` on the
* box we resize would feed our own value back in - `scrollHeight >= clientHeight`).
*
* Behaviour, per content change:
* - If a descendant is animating its own height (an inner Collapsible), we
* YIELD - clear our height so the card follows that animation 1:1 for free.
* Imposing our own transition here is the "two animations racing" bug.
* - Otherwise the change is a snap (conditional render, swapped content): pin
* the old height, force a reflow, transition to the new height, release back
* to `auto` on transitionend.
*
* Honors prefers-reduced-motion (no observer → native snap). `height` is the
* sanctioned motion exception; 200ms ease-out matches the Collapsible reveal. The
* card base carries `overflow-hidden`, which clips the content mid-animation.
*/
function AnimatedCardShell({ className, children, ...props }: React.ComponentProps<"div">) {
const outerRef = React.useRef<HTMLDivElement>(null)
const innerRef = React.useRef<HTMLDivElement>(null)
const prev = React.useRef<number | null>(null)
React.useEffect(() => {
const outer = outerRef.current
const inner = innerRef.current
if (!outer || !inner) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
const release = (e: TransitionEvent) => {
if (e.target !== outer || e.propertyName !== "height") return
outer.style.height = ""
outer.style.transition = ""
}
const follow = () => {
outer.style.height = ""
outer.style.transition = ""
}
const observer = new ResizeObserver(() => {
const next = inner.offsetHeight
// Mid-flight (inline height still set): retarget from the LIVE rendered height,
// not the cached previous target - rapid successive changes must reverse smoothly.
// Settled (auto height): outer has already snapped to the new content height by
// observer time, so the cached previous measurement is the only true from value.
const inFlight = outer.style.height !== ""
const from = inFlight ? outer.offsetHeight : prev.current
prev.current = next
// First measure records the baseline - no animation on mount.
if (from === null || from === next) return
// A child is driving its own height - ride along instead of fighting it.
if (isChildAnimatingHeight(inner)) return follow()
// Snap: animate the card's height across the discrete jump.
outer.style.height = `${from}px`
outer.getBoundingClientRect() // force reflow so the next assignment animates
outer.style.transition = "height 200ms ease-out"
outer.style.height = `${next}px`
})
// Observe the untouched inner wrapper, never the box we resize.
observer.observe(inner)
outer.addEventListener("transitionend", release)
return () => {
observer.disconnect()
outer.removeEventListener("transitionend", release)
}
}, [])
return (
<div ref={outerRef} data-slot="card" className={cn(className)} {...props}>
<div ref={innerRef} className="flex flex-col">
{children}
</div>
</div>
)
}
export { AnimatedCardShell }