Card
Cards contain content and actions about a single subject. Follows the Material Design 3 Card specification.
Usage
import { Card, Typography } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, justifyContent: 'center', padding: 16 }}>
<Card variant="elevated">
<Card.Content>
<Typography variant="titleMedium">Card Title</Typography>
<Typography variant="bodyMedium">Card content goes here.</Typography>
</Card.Content>
</Card>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
Variants
| Variant | Use case |
|---|---|
elevated | Default surface with shadow |
filled | Filled surface, no shadow |
outlined | Bordered surface, no shadow |
elevated shadows need the New Architecture on iOSA non-interactive elevated card (no onPress) paints its shadow through the CSS boxShadow surface on iOS. That is deliberate: iOS clips a view's own shadowOpacity-style shadow when the view clips its children to the corner radius, and boxShadow is the one surface the renderer paints outside the clip.
The practical consequence is that this shadow needs the New Architecture, which is the default from React Native 0.76 and cannot be turned off in 0.82+. On the old architecture the card renders flat — as it always did. Android and web are unaffected either way, and an interactive elevated card renders correctly everywhere.
Interactive Card
When onPress is provided, the card renders as a Pressable with state-layer feedback:
import { Card, Typography } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Alert, View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, justifyContent: 'center', padding: 16 }}>
<Card variant="filled" onPress={() => Alert.alert('Tapped')}>
<Card.Content>
<Typography>Tap me</Typography>
</Card.Content>
</Card>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
A card as a link
An interactive card has role="button". A role you pass replaces it. Expo Router's <Link asChild> passes role="link" and href to its child, so a card inside it renders as an <a> on the web. A crawler follows that link, and a middle click opens it in a new tab.
import { Link } from 'expo-router'
<Link href="/movie/19404" asChild>
<Card onPress={() => {}}>
<Card.Content>
<Typography>Fight Club</Typography>
</Card.Content>
</Card>
</Link>
The card needs an onPress to become interactive. Link adds its own navigation handler next to it, so the card's onPress still runs, then the navigation.
Regions
Card is a surface and applies no padding of its own. Use the region slots to get the Material Design 3 card spacing without repeating it at every call site.
| Slot | Role |
|---|---|
Card.Media | Edge-to-edge media. No padding, and the card's corner radius clips it. |
Card.Content | Padded text block (16dp) with a small gap between children. |
Card.Actions | Row of buttons, aligned to the trailing edge by default. |
import { Button, Card, Typography } from '@rootnative/components'
import { mdiResolver } from '@rootnative/components/mdi'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Alert, Image, View } from 'react-native'
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider iconResolver={mdiResolver}>
<View style={{ flex: 1, justifyContent: 'center', padding: 16 }}>
<Card variant="elevated">
<Card.Media height={160}>
<Image
source={{ uri: 'https://picsum.photos/600/400' }}
resizeMode="cover"
/>
</Card.Media>
<Card.Content>
<Typography variant="titleMedium">Yosemite</Typography>
<Typography variant="bodyMedium">
Granite cliffs, waterfalls and giant sequoia groves.
</Typography>
</Card.Content>
<Card.Actions>
<Button variant="text" onPress={() => Alert.alert('Shared')}>
Share
</Button>
<Button variant="filled" onPress={() => Alert.alert('Booked')}>
Book
</Button>
</Card.Actions>
</Card>
</View>
</ThemeProvider>
</SafeAreaProvider>
)
}
Sizing media
Card.Media needs a height from somewhere, because a React Native Image with a remote URI has no intrinsic size:
height— a fixed height in dp.aspectRatio— width / height, which keeps the media proportional as the card resizes. Ignored whenheightis set.- Neither — the media sizes itself, and its own child provides the height.
With height or aspectRatio set, each child fills the region, so a plain Image needs no style of its own. The children stack in order, so a second child is an overlay. A child's own width or height wins.
Raw children still work
The slots are additive. A card with raw children renders exactly as before and stays unpadded, so you own the spacing:
<Card>
<View style={{ padding: 16 }}>
<Typography>Hand-rolled padding</Typography>
</View>
</Card>
Prefer the slots in new code — that is what keeps spacing consistent between cards.
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | ReactNode | - | Yes | Content rendered inside the card surface. The card applies no padding of its own. Use the region slots — `Card.Media`, `Card.Content` and `Card.Actions` — to get the MD3 spacing without repeating it at every call site. Raw children remain fully supported and unpadded, so you own the spacing when you pass them. |
variant | enum | elevated | No | Surface style variant. |
onPress | () => void | - | No | When provided, the card becomes interactive (Pressable) with `role="button"`. Omit to render as a plain View. A `role` you pass replaces the default. Expo Router's `<Link asChild>` passes `role="link"` and `href`, so the card renders as an `<a>` on the web. |
disabled | boolean | | No | Disables the press interaction and reduces opacity. Only effective when `onPress` is provided. |
containerColor | string | - | No | Override the container (background) color. State-layer colors (hover, press) are derived automatically. |
style | StyleProp<ViewStyle> | - | No | Style for the card. With no `onPress`, every key styles the one surface node. With `onPress`, the card wraps its surface in an outer view that also holds the focus ring and the shadow. The keys that place or size the card in its parent — `alignSelf`, `flex`, `flexGrow`, `flexShrink`, `flexBasis`, `width`, the margins, `position`, the insets and `zIndex` — go to that outer view, so the ring and the shadow move with the card. `minWidth` and `maxWidth` go with a `width`. When `flex` or `flexGrow` is above 0, or `alignSelf` is `'stretch'`, the surface fills the outer view. Every other key styles the surface. |
hrefAttrs | { target?: string; rel?: string; download?: string | boolean; } | - | No | Web only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |
Card.Media
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | ReactNode | - | Yes | Media to render — an `Image`, `expo-image`, a video surface, or any other node. The slot does not own the image, so any library works. With `height` or `aspectRatio`, each child fills the slot, so a plain `Image` needs no style of its own. The children stack in order, so a second child is an overlay. A child's own `width` or `height` wins. |
height | number | - | No | Fixed height of the media region in dp. Omit to let the media size itself (for example an `Image` with an `aspectRatio` style). |
aspectRatio | number | - | No | Aspect ratio of the media region (width / height). Ignored when `height` is set. |
hrefAttrs | { target?: string; rel?: string; download?: string | boolean; } | - | No | Web only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |
Card.Content
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | ReactNode | - | Yes | Content rendered inside the padded region. |
hrefAttrs | { target?: string; rel?: string; download?: string | boolean; } | - | No | Web only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |
Card.Actions
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | ReactNode | - | Yes | Action buttons, laid out in a row. |
align | enum | end | No | Horizontal placement of the actions. |
hrefAttrs | { target?: string; rel?: string; download?: string | boolean; } | - | No | Web only. The `target`, `rel` and `download` of the `<a>` that a view with an `href` renders, for example under Expo Router's `<Link asChild>`. `target: '_blank'` opens the link in a new tab. Native ignores it. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |