ContentCard is a flexible, composable card component built with ContentCardHeader, ContentCardBody, and ContentCardFooter sub-components. It can display various content layouts including text, media, and interactive elements.
Semantic HTMLContentCard and its sub-components render semantic HTML elements by default:
ContentCard renders as <article>
ContentCardHeader renders as <header>
ContentCardFooter renders as <footer>
You can override these defaults using the as prop on each component.
Basic Examples
ContentCard uses sub-components for flexible layout. Combine ContentCardHeader, ContentCardBody, and ContentCardFooter to create your card structure.
function Example() {
return (
<VStack gap={2} maxWidth={375}>
<ContentCard>
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
actions={
<HStack gap={0}>
<IconButton
transparent
accessibilityLabel="favorite"
name="star"
variant="secondary"
/>
<IconButton
transparent
accessibilityLabel="More options"
name="more"
variant="secondary"
/>
</HStack>
}
/>
<ContentCardBody
title="Ethereum Network Shatters Records With Hashrate Climbing to 464 EH/s"
description="This is a description of the record-breaking hashrate milestone."
label={
<HStack alignItems="flex-end" flexWrap="wrap" gap={0.5}>
<Text as="p" color="fgMuted" font="label2" numberOfLines={1}>
$3,081.01
</Text>
<Text as="p" color="fgPositive" font="label2">
↗ 6.37%
</Text>
</HStack>
}
/>
<ContentCardFooter>
<RemoteImageGroup shape="circle" size={32}>
<RemoteImage src={assets.eth.imageUrl} />
<RemoteImage src={assets.btc.imageUrl} />
</RemoteImageGroup>
<Button compact variant="secondary">
Share
</Button>
</ContentCardFooter>
</ContentCard>
</VStack>
);
}
Use the mediaPlacement prop on ContentCardBody to control where media is positioned relative to the content.
function Example() {
const exampleMedia = (
<RemoteImage alt="Ethereum background" source={ethBackground} width="100%" />
);
return (
<VStack gap={2} maxWidth={375}>
<Text as="h3" font="headline">
mediaPlacement: top (default)
</Text>
<ContentCard>
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="Media at top"
description="The media appears above the text content."
media={exampleMedia}
mediaPlacement="top"
/>
</ContentCard>
<Text as="h3" font="headline">
mediaPlacement: bottom
</Text>
<ContentCard>
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="Media at bottom"
description="The media appears below the text content."
media={exampleMedia}
mediaPlacement="bottom"
/>
</ContentCard>
<Text as="h3" font="headline">
mediaPlacement: end
</Text>
<ContentCard>
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="Media at end"
description="The media appears to the right of the text content."
media={exampleMedia}
mediaPlacement="end"
/>
</ContentCard>
<Text as="h3" font="headline">
mediaPlacement: start
</Text>
<ContentCard>
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="Media at start"
description="The media appears to the left of the text content."
media={exampleMedia}
mediaPlacement="start"
/>
</ContentCard>
</VStack>
);
}
With Background
Apply a background color to the card using the background prop. When using a background, consider using variant="tertiary" on buttons.
function Example() {
const exampleMedia = (
<RemoteImage
alt="Ethereum background"
src={ethBackground}
style={{ objectFit: 'cover', borderRadius: '24px' }}
width="100%"
/>
);
return (
<VStack gap={2} maxWidth={375}>
<ContentCard background="bgAlternate">
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="Card with Background"
description="This card has an alternate background color."
media={exampleMedia}
/>
<ContentCardFooter>
<RemoteImageGroup shape="circle" size={32}>
<RemoteImage src={assets.eth.imageUrl} />
<RemoteImage src={assets.btc.imageUrl} />
</RemoteImageGroup>
<Button compact variant="tertiary">
Share
</Button>
</ContentCardFooter>
</ContentCard>
<ContentCard background="bgAlternate">
<ContentCardHeader
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
subtitle="News"
/>
<ContentCardBody
title="No Media with Background"
description="This card has no media element."
/>
<ContentCardFooter gap={4} justifyContent="space-between" paddingTop={0.5}>
<IconCounterButton accessibilityLabel="like, 99 likes" count={99} icon="heart" />
<IconCounterButton
accessibilityLabel="comment, 4200 comments"
count={4200}
icon="comment"
/>
<IconCounterButton
accessibilityLabel="share, 32 shares"
count={32}
icon="arrowsHorizontal"
/>
</ContentCardFooter>
</ContentCard>
</VStack>
);
}
Rewards Card Example
Example showing a rewards-style content card with claim button.
function Example() {
return (
<VStack gap={2} maxWidth={375}>
<ContentCard gap={3}>
<ContentCardBody
title={
<Text as="p" font="body" paddingTop={0.5}>
Bitcoin Network Shatters Records With Hashrate Climbing to 464 EH/s
</Text>
}
label={
<HStack alignItems="flex-end" flexWrap="wrap" gap={0.5}>
<Text as="p" color="fgMuted" font="label2" numberOfLines={1}>
BTC
</Text>
<Text as="p" color="fgPositive" font="label2">
↗ 5.12%
</Text>
</HStack>
}
media={
<RemoteImage
alt="Rewards banner"
src={ethBackground}
style={{ objectFit: 'cover', borderRadius: '24px' }}
width="100%"
/>
}
/>
<ContentCardFooter alignItems="center">
<HStack alignItems="center" gap={1}>
<Avatar src={assets.btc.imageUrl} size="xl" />
<VStack>
<TextLegal as="span" color="fgMuted">
Reward
</TextLegal>
<Text as="span" font="headline">
+$15 ACS
</Text>
</VStack>
</HStack>
<Button compact accessibilityLabel="Claim now" variant="secondary">
Claim Now
</Button>
</ContentCardFooter>
</ContentCard>
</VStack>
);
}
Accessibility
Interactive Cards
When making ContentCard interactive, wrap it in a Pressable component and handle accessibility carefully to avoid nested interactive elements.
The Problem: If you wrap ContentCard in a Pressable and also have interactive elements inside (like buttons), the card becomes a clickable container with nested interactive elements. This creates accessibility issues for screen reader users.
The Solution: Use as="div" on the Pressable wrapper and add a separate action button inside the card. This allows:
- Regular users to click anywhere on the card
- Screen reader users to navigate through card content and focus on individual interactive elements
- Keyboard users to tab to the action button
function AccessibleCard() {
return (
<Pressable
as="div"
background="bgAlternate"
borderRadius={500}
onClick={() => alert('Card clicked - navigating...')}
width="fit-content"
>
<ContentCard maxWidth={375}>
<ContentCardHeader
subtitle="News"
thumbnail={<RemoteImage alt="Ethereum" shape="circle" size="l" source={ethBackground} />}
title="CoinDesk"
/>
<ContentCardBody
title="Accessible Interactive Card"
description="Card with both card-level click and internal action button"
/>
<ContentCardFooter alignItems="center">
<Text as="span" color="fgMuted" font="legal">
2 hours ago
</Text>
<Button
compact
variant="tertiary"
onClick={(event) => {
event.stopPropagation();
alert('Button clicked - navigating...');
}}
>
View Details
</Button>
</ContentCardFooter>
</ContentCard>
</Pressable>
);
}
Key points:
- Use
as="div" on the Pressable to avoid rendering as a semantic button
- When using
as="div", the Pressable remains keyboard focusable. Set tabIndex={-1} to remove it from the tab order if needed
- Call
event.stopPropagation() at the beginning of the event handler method passed into the onClick prop for action buttons. This will prevent two click events from firing if the user directly clicks the action button.
Avoid Nested Interactive ElementsWhen ContentCard is wrapped in an interactive Pressable, avoid placing too many interactive elements inside the card. Each interactive element should have a clear, distinct purpose. If the card has many actions, consider using a non-interactive card layout instead.
Color Contrast
When customizing card backgrounds, ensure sufficient color contrast between text and background colors. Use tools like the WebAIM Contrast Checker to verify your color combinations meet WCAG guidelines.