Skip to main content

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.

Loading example

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.

Loading example

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.

Loading example

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.

Loading example

Props

CardProps<TTag> is polymorphic: as="a" types href, as={Link} types to. The own props are:

PropTypeDefaultDescription
variant'elevated' | 'outline' | 'ghost''elevated'
padding'none' | 'sm' | 'md' | 'lg''md'Sets --card-padding from --card-padding-sm/md/lg
interactivebooleanfalseThe card is one control: hover / active styles, focus-visible ring, cursor-pointer; a plain tag gets role="button", tabIndex 0 and Enter / Space activation
disabledbooleanfalseDims (data-disabled) and blocks onClick; native disabled on a button, else aria-disabled + tabIndex -1. A pass-through aria-disabled="true" behaves the same
asElementType'div'Polymorphic tag or component; as="button" gets type="button"
classNamestringMerged last, after the variant and padding classes

Parts

Also available as named exports (CardHeader, CardTitle, …).

PropTypeDefaultDescription
Card.HeaderComponentPropsWithoutRef<'div'> & { action?: ReactNode }Column of title + description; with action a row with the control pinned to the end. Not for an interactive card
Card.TitlePolymorphicProps<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.DescriptionComponentPropsWithoutRef<'p'>A p in text-muted-foreground; a span inside a button card
Card.ContentComponentPropsWithoutRef<'div'>The body; reads the card padding
Card.FooterComponentPropsWithoutRef<'div'>A flex row with gap-2; justify-end / justify-between via className

Used outside a Card the parts pad themselves at md.

Slots

SlotElementNotes
[data-slot="card"]div (or as)Root. Also data-variant, data-padding, data-interactive, data-disabled
[data-slot="card-header"]div | spanAlso data-padding
[data-slot="card-header-text"]div | spanPresent with action: wraps the children
[data-slot="card-header-action"]div | spanPresent with action
[data-slot="card-title"]h3 (or as) | span
[data-slot="card-description"]p | span
[data-slot="card-content"]div | spanAlso data-padding
[data-slot="card-footer"]div | spanAlso 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.Title names the card: it takes a generated id and the root gets aria-labelledby after hydration, so it never dangles. This gives as="section" / as="article" landmarks and interactive cards a name from the title rather than their whole text. Your own aria-label / aria-labelledby wins.
  • interactive on a plain tag adds role="button", tabIndex={0} and Enter / Space activation (Space is prevented from scrolling). as="a" and as="button" rely on the native element. A router Link is trusted to render a focusable control.
  • Keyboard focus shows the shared --ring ring (focus-visible only).
  • disabled: a native button gets the disabled attribute; any other tag gets aria-disabled="true" and tabIndex={-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.
  • Container and Stack: lay cards out on the page.
  • Button: the actions in a Card.Footer.
  • Theming: --card, --card-foreground, --shadow-card and the padding scale.