Card
A card groups related content on one surface. The root sets a single
--card-padding custom property and every part reads it, so density is one
knob: pass padding or define --card-padding-sm/md/lg in your tokens.
import Card from '@zuilib/components/card'
Anatomy
Card.Header stacks Card.Title and Card.Description; an action
pins to the end of the header row. Card.Content is the body,
Card.Footer a row for actions.
Variants
elevated is bg-card with a border and shadow-card; outline drops
the shadow; ghost is transparent with a transparent border, so it keeps
its box without drawing one.
Padding
padding sets --card-padding on the root from --card-padding-sm/md/lg
(each falls back to a --spacing multiple: 4, 6, 8). The parts pad
horizontally and the root pads vertically and gaps the parts, all from
that one value. none is for a card that lays out its own content, an
image edge to edge or a table.
Interactive
interactive makes the card itself the control: hover and active
feedback, a focus-visible ring, a pointer cursor. Choose the tag for the
job: as="a" (or a router Link) for navigation, as="button" for an
action, and the default div gets role="button", tabIndex={0} and
Enter / Space activation of onClick. Inside as="button" the parts
render as spans because a native button only allows phrasing content.
disabled dims the card and blocks the click from pointer and keyboard.
An interactive card is one control, so do not put another control inside
it (Card.Header action, a Button in the footer); Card.Header warns in
development when that happens.
Props
CardProps<TTag> is polymorphic: as="a" types href, as={Link}
types to. The own props are:
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'elevated' | 'outline' | 'ghost' | 'elevated' | |
padding | 'none' | 'sm' | 'md' | 'lg' | 'md' | Sets --card-padding from --card-padding-sm/md/lg |
interactive | boolean | false | The card is one control: hover / active styles, focus-visible ring, cursor-pointer; a plain tag gets role="button", tabIndex 0 and Enter / Space activation |
disabled | boolean | false | Dims (data-disabled) and blocks onClick; native disabled on a button, else aria-disabled + tabIndex -1. A pass-through aria-disabled="true" behaves the same |
as | ElementType | 'div' | Polymorphic tag or component; as="button" gets type="button" |
className | string | — | Merged last, after the variant and padding classes |
Parts
Also available as named exports (CardHeader, CardTitle, …).
| Prop | Type | Default | Description |
|---|---|---|---|
Card.Header | ComponentPropsWithoutRef<'div'> & { action?: ReactNode } | — | Column of title + description; with action a row with the control pinned to the end. Not for an interactive card |
Card.Title | PolymorphicProps<TTag, { id?, className?, children? }> | as='h3' | Takes the id the Card is aria-labelledby; a span inside a button card. Pass as="h2" for the page outline |
Card.Description | ComponentPropsWithoutRef<'p'> | — | A p in text-muted-foreground; a span inside a button card |
Card.Content | ComponentPropsWithoutRef<'div'> | — | The body; reads the card padding |
Card.Footer | ComponentPropsWithoutRef<'div'> | — | A flex row with gap-2; justify-end / justify-between via className |
Used outside a Card the parts pad themselves at md.
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="card"] | div (or as) | Root. Also data-variant, data-padding, data-interactive, data-disabled |
[data-slot="card-header"] | div | span | Also data-padding |
[data-slot="card-header-text"] | div | span | Present with action: wraps the children |
[data-slot="card-header-action"] | div | span | Present with action |
[data-slot="card-title"] | h3 (or as) | span | — |
[data-slot="card-description"] | p | span | — |
[data-slot="card-content"] | div | span | Also data-padding |
[data-slot="card-footer"] | div | span | Also data-padding |
/* Consumer tokens: a denser card scale everywhere */
:root {
--card-padding-sm: calc(var(--spacing) * 3);
--card-padding-md: calc(var(--spacing) * 4);
--card-padding-lg: calc(var(--spacing) * 6);
}
Accessibility
- A mounted
Card.Titlenames the card: it takes a generated id and the root getsaria-labelledbyafter hydration, so it never dangles. This givesas="section"/as="article"landmarks and interactive cards a name from the title rather than their whole text. Your ownaria-label/aria-labelledbywins. interactiveon a plain tag addsrole="button",tabIndex={0}and Enter / Space activation (Space is prevented from scrolling).as="a"andas="button"rely on the native element. A router Link is trusted to render a focusable control.- Keyboard focus shows the shared
--ringring (focus-visibleonly). disabled: a native button gets thedisabledattribute; any other tag getsaria-disabled="true"andtabIndex={-1}, and clicks are swallowed.- Nested controls inside an interactive card are invalid markup and unreachable for assistive tech; use a plain card with controls in its parts.