BetaLayout

Card

A surface that groups related content — and, with isPressable, the control that opens it.

Overview

A surface that groups related content — and, with isPressable, the control that opens it.

First example

<Card>
<Card.Body>
<Card.Title>Facture #1024</Card.Title>
<Card.Description>Émise le 3 mars, échéance le 2 avril.</Card.Description>
</Card.Body>
</Card>

Anatomy

<Card>
  <Card.Header />
  <Card.Body>
    <Card.Title />
    <Card.Description />
  </Card.Body>
  <Card.Footer />
</Card>
  • Card — the root. A View, or a PressableFeedback when isPressable is set. It resolves the recipe once and publishes the resolved styles to its slots. A string child is wrapped in a Card.Description automatically.
  • Card.Header — the top section: a badge, an icon, an eyebrow. A column pinned to the leading edge; flexDirection="row" makes it a title-and-action bar.
  • Card.Body — the main section, and the one that grows. Given a card with a height, it takes what the header and the footer leave.
  • Card.Footer — the bottom section. A row, because a footer is an action row.
  • Card.Title — the heading. It wraps, where Button.Label truncates: a card has no fixed height to deform.
  • Card.Description — the prose. It sits behind the title at 60% of the title's own colour, which composites to the muted token on both surfaces and still holds on a tinted card, where a fixed grey would stop being readable.

Every slot reads the root's context, not its parent's. Card.Title and Card.Description are children of the card, not of the body, so the tree above is a convention rather than a constraint — a title directly under Card styles identically. Nesting them in the body is the form to prefer, because it is the body's gap that sets them apart and the body that grows.

No slot carries a margin (R4). What separates the sections is the root's gap, so JSX order is screen order and nothing has to be undone to reorder them. The sections are optional and independent: a card that is a title and a description needs no footer.

Usage

Basic

<Card>
  <Card.Body>
    <Card.Title>Facture #1024</Card.Title>
    <Card.Description>Émise le 3 mars, échéance le 2 avril.</Card.Description>
  </Card.Body>
</Card>

A stringifiable tree becomes a Card.Description on its own (R3) — Description and not Title, because a card with prose and no heading is ordinary and the reverse is not:

<Card>Une carte qui ne dit qu'une chose.</Card>

Composed

<Card>
  <Card.Background />
  <Card.Header>
    <Chip size="sm">En retard</Chip>
  </Card.Header>

  <Card.Body>
    <Card.Title>Facture #1024</Card.Title>
    <Card.Description>Trois lignes, 1 240 € hors taxes.</Card.Description>
  </Card.Body>

  <Card.Footer>
    <Button size="sm">Payer</Button>
    <Button size="sm" variant="ghost">
      Plus tard
    </Button>
  </Card.Footer>
</Card>

Nothing is spaced by hand: the root's gap separates the three sections and the body's own gap separates the title from the description. That is what R4 buys — the layout is the component's, not a set of margin classes at the call site.

The header is a column and the footer a row, which are the two common cases. Either flips with one style prop (R14):

<Card.Header flexDirection="row" justifyContent="space-between">
  <Card.Title>Activité</Card.Title>
  <Button size="xs" variant="tertiary">
    Voir
  </Button>
</Card.Header>

Pressable

<Card isPressable onPress={open}>
  <Card.Header>
    <Card.Title>Facture #1024</Card.Title>
  </Card.Header>
</Card>

isPressable is a prop and not an inference from onPress being present, because the two answers are different elements — a View and a Pressable. Inferring it would remount the card, and change what a screen reader announces, on the render where a handler happens to become undefined. A press handler written without it warns in development.

As another element

<Card asChild>
  <Link href="/invoices/1024">
    <Card.Title>Facture #1024</Card.Title>
  </Link>
</Card>

Under asChild the caller's element is the card: it receives the ref, the styles and the handlers. There is no auto-wrap and, on a pressable card, no press wash — the element is the pressable, and there is no sibling to paint under it.

Sizes

<Card size="xs" /> <Card size="sm" /> <Card size="md" /> <Card size="lg" />

size drives padding, both gaps, the radius and the type of Title and Descriptionnever a height. A card is a surface: it is as tall as what it holds, and as wide as its parent lets it be. There is no fullWidth; width="100%" and alignSelf are the answer, as everywhere else.

Two gaps move with it, not one. gap separates the sections; the gap inside a section separates a title from its description. One value for both would read as a list of five things rather than as three blocks.

Variants

Four levels, and they are the Button's own names — that is what makes this a subtype of one vocabulary rather than a second one. A card reports nothing, so success, warning and danger are absent: a card coloured by an outcome is a card holding a Chip or an Alert that carries it.

variantSurfaceEdgeElevation
defaultsurfaceshadows.surface
secondarysurfaceSecondary
tertiaryborder
ghost

A filled card has no border: the fill and the elevation already separate it from the background, and an edge on top of them is a third signal saying the same thing — which is what makes a list of cards read as a table. tertiary names one because there it is the only signal.

The elevation belongs to the one variant that is a surface standing on the background. secondary is the level for a card inside a card, and tertiary and ghost have no fill to lift, so a shadow under any of them would read as dirt rather than as height. In dark mode the theme's surface shadow is already nothing, which is why the recipe names the role instead of a set of numbers.

Colour

<Card color="#7c3aed">…</Card>
<Card variant="tertiary" color="#7c3aed">…</Card>

One raw tint, placed by the variant: the fill of a default, the border of a tertiary, the text of a ghost. Its contrasted slice is derived in OKLab, so the title stays readable on the fill without a second colour being named — and the description follows, because it is a fraction of the title's colour rather than a fixed grey.

Style as props

<Card padding={32} width="70%">…</Card>
<Card.Title fontSize={24} letterSpacing={-0.5}>…</Card.Title>

Full React Native names, so full React Native values: padding={32} is 32 points, never a step on a scale. They resolve after the recipe and before the node's own style, and they are scoped to the node they are written on — a padding on Card.Body pads the body, not the card.

Everything else goes through style

A tinted shadow, a gradient, a transform, a per-platform value: style, on the root or on the slot that needs it. Two cases worth naming:

  • A full-bleed image needs overflow="hidden" on the root, which the card does not set itself: on iOS it clips the node's own shadow, and a default card would lose the elevation its variant just gave it.
  • A border in a different colour than the fill is borderColor and borderWidth as style props — the variant names one token for the edge, and a second one is not a variant.

Card.Background — ce que la carte pose derrière elle

<Card variant="ghost" height={180} justifyContent="flex-end">
  <Card.Background source={{ uri: cover }} />
  <Card.Header>
    <Card.Title color="#fff">Marrakech</Card.Title>
  </Card.Header>
</Card>

The root paints it first, wherever it is written. JSX order decides stacking for absolutely positioned siblings, so a background written after the header would cover it. The root hoists the slot instead — the mechanism PressableFeedback already uses for its overlays — which is why it is a marked component rather than a View the caller positions. markBackground is exported, so a third party's gradient or video layer gets the same treatment.

The clip lives on the layer, not on the root. overflow: 'hidden' cuts the node's own shadow on iOS, so putting it on the card would cost a default card the elevation its variant just gave it. This slot carries its own overflow and the card's radius — which is also why radius moves both slots together. The reference implementation clips on both and loses the shadow.

Two forms, like Icon: source renders the image, and anything else is the caller's own layer, already positioned and clipped.

Root props

CardProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
variantCardVariant | undefinedNo
sizeSize | undefinedNoPadding, gaps, radius and the type of `Title` and `Description`. **Never a height**: a card is a surface, not a control, and it is as tall as what it holds.
radiusRadiusKey | undefinedNoOverrides the radius `size` chose. Unset, a card is the shape its size implies.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7). Where it lands follows the variant, as everywhere else: the fill of a `default`, the border of a `tertiary`, the text of a `ghost`. Its contrasted slice is derived in OKLab, so a tinted card's title stays readable on it without being told a second colour.
isDisabledboolean | undefinedNoDims the card and, on a pressable one, stops the touch. A static card is only dimmed — it announces nothing, because there was nothing to disable.
isPressableboolean | undefinedNoMakes the card a control: a `PressableFeedback` with `accessibilityRole="button"`, the shared scale, and a press wash over the surface. It is a prop and not an inference from `onPress` being present, because the two answers are different **elements** — a `View` and a `Pressable` — and inferring it would remount the card, and change what a screen reader announces, on the render where a handler happens to become `undefined`.
styleStyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>)NoR9 — `Pressable`'s function form as much as an object or an array.
childrenReactNodeNo

Slots

CardBackgroundProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNoAnything else — a gradient, a video, a blur — already positioned and clipped.
sourceImageSourcePropType | undefinedNoAn image, stretched to fill the layer and clipped to the card's corner.
styleStyleProp<ViewStyle>No

CardBodyProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

CardDescriptionProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

CardFooterProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

CardHeaderProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

CardTitleProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

Variants, sizes and colour

This component adds no visual axis of its own. The values it does take are in the generated types above and in the live demo.

Accessibility

  • A static card has no role of its own and announces nothing: it is a box, and the text inside it is what a screen reader reads. accessibilityRole is yours to set when the card is a landmark.
  • A pressable card is accessibilityRole="button" by default and stays overridable, and its accessibilityState carries disabled merged with any state you pass — adding expanded or selected does not erase it.
  • A pressable card is one target. Nesting a Button inside one gives a screen reader two overlapping controls and a sighted user an ambiguous tap — put the actions in the footer of a static card instead.
  • Title and Description are real Text, so they are read and they reflow at large font sizes rather than truncating.

Migration from legacy

Legacyv1
themeColor="default"variant="default"
themeColor="primary"color={theme.colors.accent}
padding={16}size, or padding={16} as a style prop
elevation={2}variant="default" — the surface shadow is the variant's
fullWidthremoved — that is already the default in a column
isPressableisPressable — now a PressableFeedback
isHoverableremoved — there is no hover on a touch surface
isBlurred / isFooterBlurredremoved — a glass theme, not a card prop. Out of 1.0
disableAnimationanimation={false}
disableRippleremoved — a card washes, it does not ripple
customAppearance={{ container }}style on the root
customAppearance={{ header, body, footer }}style on Card.Header / .Body / .Footer
customAppearance={{ title, description }}style on Card.Title / .Description

Implementation notes

Touch feedback

A pressable card scales and takes a wash, where the Button scales and uses its variant's own …Pressed token. A component picks one pressed treatment, never both, and the card picks the other one for two reasons: it has no pressed token per surface level to swap to, and on an area this large a flat overlay says "under the finger" better than a fill a shade darker does.

The wash contrasts with whatever the card is: the root reads the ink off its own resolved background, so a tinted card, an outlined one and a ghost one each get an overlay that is visible on them. It rounds itself to the card's corners, which is why the root needs no overflow: 'hidden' — and must not have one, or the iOS shadow goes with it.

animation={false} renders a different component rather than the same one with a branch inside — no Reanimated hook is reached at all. 'disable-all' does the same for every descendant, which is how a list of cards switches its rows off with one prop.

Extending it

The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read:

import { useCard } from '@xaui/native/card'

function CardMedia({ source }) {
  const { bodyStyle } = useCard()
  return <Image style={bodyStyle} source={source} />
}

Used outside a <Card> it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style.