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
Header, content, and footer are each optional. This one skips CardContent and lets the description carry the body, which is the shape a short confirmation card wants.
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 to CardFooter to close the card off with a divider. It supplies its own 20px above the actions as soon as it is bordered, matching the 20px already below them, so there is no pt-* to remember.
With Form
A form inside a card. Three relationships, three rungs: field between the fields, label from a label to its control, and a reopened top gap, since CardContent hugs a borderless header at 8px and a form needs its own room.
With Action
CardAction places a control in the top-right of the header. The body is CardRows + CardRow rather than a hand-rolled stack: a member list is a row list, so each row owns its padding and hairline and there is no inter-row gap to guess.
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.
Header Action Alignment
Action seating, default flush. A Badge, count, or caption puts its own edge on the 16px inset, level with the title. An icon Button or Kebab carries ~6px of hit-area chrome past its glyph, so it needs actionInset="icon" to seat the glyph there; using it on a bare Badge pulls the count 6px too tight.
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
A card whose list is filtered by tabs. Keep the tabs out of DashboardCardHeader: its band is sized for one 32px icon action, so a strip crammed in there sits off-center. The header takes the title and one action, the tabs get their own region below, and a border-t closes them off from the list.
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. |
| actionInsetDashboardCardHeader | "icon" | "flush" | "flush" | How the action seats against the right edge. flush (default) puts the action's own box edge on the content inset, so a Badge, count, or caption aligns with the title. icon outdents 6px for an action whose box extends past what you see (an icon Button or Kebab's transparent hit area, a ghost text button's padding), seating the glyph on the inset instead. The default is flush because a badge dropped in unadorned must look right with no flag; the button case declares its own chrome compensation. |
| linkDashboardCardHeader | { label: string; onClick: () => void } | - | Subtle text-button link on the right of the header; ignored when action is set. |
| insetDashboardCardHeader | "default" | "loose" | "default" | Side padding, which must match the body's below it. default (16px) is the row tier and fits most cards. loose (24px) is the prose tier, for a card whose body is paragraphs rather than rows, where 16px reads thin once the card is wide. Set the body to match in the same edit: the header and the content share one left edge, so moving either alone puts the title 8px inboard of every line beneath it. |
| 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"
// @use-when a bounded surface holding related content: a form panel, a
// summary, a settings group.
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, style, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
// No pb by default: whatever follows owns the gap, which is why
// CardContent carries its own pt. A FOOTER cannot, though. Its pt-5
// sits below its own border-t, and the missing space is above that
// line, on the header's side, so a header followed straight by a
// bordered footer put the rule on the description's descenders. The
// header has to supply it, and it does so whether or not the footer is
// bordered: a borderless footer was told to borrow 20px from the
// content above it, and with no content above it there was none.
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start px-4 pt-5 [&:has(+[data-slot=card-footer])]:pb-5 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className
)}
// Title-to-description gap is the pair rung, set via inline style: a
// gap-* class would freeze the compiled length (see stack.tsx /
// page-header.tsx for the same move).
style={{ ...style, gap: "var(--spacing-rhythm-pair)" }}
{...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-pretty, NOT text-balance. See dialog.tsx for the full reason; the
// short version is that balance narrows the block to its tightest fit, so
// a wrapped title stops short of the card's right edge while the
// description under it runs to the edge, and two left-aligned blocks with
// different right edges read as a mistake. pretty keeps the full width
// and only pulls a word down to kill an orphan, which was the actual
// defect balance was hired to fix.
className={cn("leading-snug font-semibold text-pretty", 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
// 8px so the proximity gradient reads 20 (edge) > 8 (header) > 4
// (title/description) and the title binds to its body. A card title is
// 16px semibold against 14px body: sizes that close together need the
// gap to do the grouping, and at 12px the two read as separate blocks.
// 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-2",
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: 48px height, title left, optional action right */
function DashboardCardHeader({
title,
action,
actionInset = "flush",
link,
inset = "default",
className,
...props
}: {
/** A string takes the header's own type treatment. Pass a node when the
* header's identity IS a control (a record picker, a tab set): a node is
* rendered raw, because truncate + font-medium are a label's styling and a
* control brings its own. This slot exists so such a header composes the
* component instead of re-typing its chrome, which is how a fourth card
* header height enters a codebase. */
title: React.ReactNode
action?: React.ReactNode
/**
* How the action seats against the right edge.
*
* `flush` (default) puts the action's own box edge on the content inset, so a
* Badge, a count, or a caption aligns with the title opposite it. This is the
* right answer whenever the action's visible edge IS its box edge.
*
* `icon` outdents by 6px, for an action whose box extends past what you see:
* an icon Button or Kebab carries transparent hit-area chrome between the
* glyph and the box edge, and a ghost text button carries side padding. Left
* flush, that chrome pushes the glyph inboard of the inset and the header
* reads lopsided. The outdent seats the GLYPH on the inset instead. It does
* not scale with `inset`: both edges move together, so the correction holds.
*
* The default is `flush` on purpose. A badge or count dropped in unadorned is
* the common case and must look right with no flag; the button case is the
* one that knows it needs chrome compensation, so it is the one that declares
* it.
*/
actionInset?: "icon" | "flush"
/** Standard header link - renders as subtle text button on the right */
link?: { label: string; onClick: () => void }
/**
* Side padding, which must match the BODY's below it.
*
* A card header and the content under it share one left edge, and mixing the
* two tiers is a visible defect rather than a rounding error: the title lands
* 8px inboard of every line beneath it and the card reads as having two left
* edges. So this is a prop, not a `className` override, because the two have
* to be chosen together and a prop is the only version of that choice a
* reader can see at the call site.
*
* `default` (16px) is the row tier: a card of table rows, metrics, list
* items, which is most of them. `loose` (24px) is the prose tier, for a card
* whose body is paragraphs rather than rows; 16px reads thin once a card is
* wide and the lines inside it are long. Nothing else is offered on purpose:
* a third inset is a third left edge to keep in agreement.
*/
inset?: "default" | "loose"
// "title" is omitted as well as "children": a div carries an HTML title
// attribute typed `string`, and intersecting it with this prop is what
// silently pinned the slot to strings.
} & Omit<React.ComponentProps<"div">, "children" | "title">) {
const rightContent = action ? (
// The outdent is opt-in via `actionInset` (see its doc above): only an
// action whose box extends past its glyph wants it, and the default is a
// flush badge/caption/count that must not be pulled off the inset.
<span className={cn("flex items-center", actionInset === "icon" && "-mr-1.5")}>
{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 border-b border-border bg-muted",
inset === "loose" ? "px-6" : "px-4",
rightContent && "justify-between",
className
)}
{...props}
>
{typeof title === "string" ? (
<span className="text-sm leading-normal font-medium truncate min-w-0">{title}</span>
) : (
title
)}
{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
/** The line under the value, e.g. "Goal: 262". OPTIONAL: omit it and the line
* is not rendered at all, rather than rendered empty. A metric whose unit
* reads better beside the number belongs in `children`, which is baseline-
* aligned with it. */
goal?: React.ReactNode
delta?: { value: number; label?: string }
unit?: string
/** A short inline annotation beside the value, e.g. a delta Badge. Rendered
* in a zero-height wrapper so it cannot set the row's height and shift the
* line below it, which means it overflows the row rather than growing it:
* keep it small. Anything tall enough to matter (a real control) belongs
* under the value block, not in this slot. */
children?: React.ReactNode
} & Pick<React.ComponentProps<"div">, "className">) {
return (
// Every line here is cap-trimmed, and the gap is therefore the trimmed
// rung. On the plain `pair` rung (4px) with untrimmed boxes this block
// painted 15.4px between the value and the goal and 14.1px between the goal
// and the delta: not only ~3.7x the number it asked for, but UNEVEN, since
// the 20px value sheds more dead space than the 13px lines under it. One
// gap, two different results, which is what read as floaty. Trimmed, both
// paint the rung exactly and match each other.
//
// The rung is `lines`, not `trim-pair`. Both are the trimmed rendering
// of a tight binding and both now sit at 12px, the way `bind`, `label` and
// `help` share 8px: same number, different question, and they are free to
// move apart again. `trim-pair` is the TWO-line case, a title and the
// one description under it; `lines` is three or more short lines reading as
// one unit, and it fills a real hole in the ladder, which jumped 8 to 16.
// Name the one you mean, because whichever gets re-tuned next, only the
// call sites asking that question should follow it.
//
// The trim goes on the value SPAN, not the row around it: a flex container
// establishes no inline formatting context, so it has no line boxes and
// `text-box-trim` on it would be silently inert. The span is a flex item,
// so it is blockified and does have them. `items-baseline` is unaffected,
// since trimming moves the box, never the baseline.
<div
className={cn("flex flex-col", className)}
style={{ gap: "var(--spacing-rhythm-lines)" }}
>
{/* THE BADGE MUST NOT SET THIS ROW'S HEIGHT, or the line under it moves.
A baseline-aligned row is as tall as `max(above-baseline) +
max(below-baseline)`. Cap-trimming the value took its below-baseline
extent to ZERO, which is the point, but it also handed that edge to
whatever else is in the row: a `sm` Badge hangs about 5.7px under the
baseline, so a card WITH a badge grew 5.7px taller than one without
and its goal line sat lower. Three cards in a row, two with badges,
three different second-line positions.
The zero-height wrapper is what fixes it. The badge contributes
nothing to the row's cross size and overflows it instead, centred on
the number's cap box, which is where a pill beside a number wants to
sit anyway. The overflow is ~3px each way, well inside CardContent's
20px padding, so nothing clips.
This is why the slot is for a SHORT INLINE ANNOTATION and not a
control: something 32px tall would overflow 16px each way and reach
the goal line 12px below. */}
<div className="flex items-baseline gap-2">
<span className="cap-trim text-xl font-semibold tabular-nums">{value}</span>
{children && (
<span className="flex h-0 shrink-0 items-center self-center">{children}</span>
)}
</div>
{/* CONDITIONAL, because an empty one is not free. This span is a flex item
in a `gap: lines` column, and gap applies BETWEEN items whatever their
size, so a card passing no `goal` paid a full 12px of dead space under
its value for a line with nothing in it. Rendering no element is the
only way to render no gap.
`!= null` on purpose, so it catches undefined and null and nothing
else: a caller passing `0` or `""` asked for that line and gets it. */}
{/* ONE LINE, ending in an ellipsis. A wrapped goal line made one card in
a row a line taller, which pushed its content down against its
neighbours. The sideways cut is `overflow-x-clip`, never `truncate`:
truncate's overflow-hidden clips BOTH axes, and this line is
cap-trimmed, so its box ends at the baseline and every descender
(the p in a URL, a comma) paints below it and was cut off. `clip` on
one axis leaves the other visible, which `hidden` cannot, and
`text-overflow` still applies to it. */}
{goal != null && (
<span className="cap-trim min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap text-xs text-muted-foreground">
{goal}{unit && ` ${unit}`}
</span>
)}
{delta !== undefined && delta.value > 0 && (
<span className="cap-trim text-xs text-subtle-foreground">{delta.label ?? `${delta.value} to go`}</span>
)}
{delta !== undefined && delta.value <= 0 && (
<span className="cap-trim 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-2xs font-semibold uppercase tracking-wide 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"
// No top padding by default: the content above already ends on its own
// pb-5, so the footer borrows that 20px. A `border-t` cuts the borrowed
// space off at the line, which leaves the actions in a closed band with
// a rule above and the card edge below. Both are lines of the same
// weight, so the eye centers the buttons between them and any asymmetry
// reads as a mistake: the bordered footer therefore restores its own
// 20px on its side of the rule. This used to be a caller-supplied
// `pt-4`, which shipped a band that was 16 above and 20 below.
className={cn("flex items-center px-4 pb-5 [&.border-t]:pt-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"
// @use-when a card whose content changes size and should grow or shrink
// smoothly instead of jumping.
/** 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.
* Overflow is clipped inline only while a height animation runs and cleared in
* `release`, so a non-card host (a dialog body) clips mid-animation but returns
* to visible at rest; a Card keeps clipping either way via its base class.
*/
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 = ""
// Cleared back to the stylesheet, not forced visible: a Card's base class
// still carries overflow-hidden, so cards keep clipping. A non-card host
// (a dialog body) returns to visible, so focus rings are not clipped at
// rest - only while a height animation is actually running.
outer.style.overflow = ""
}
const follow = () => {
outer.style.height = ""
outer.style.transition = ""
outer.style.overflow = ""
}
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. Clip while
// the box is shorter than its content, or a grow reveals the new content
// spilling below the animating edge. Released in `release` on transitionend.
outer.style.overflow = "hidden"
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 }