diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs
index d5d4c026b..b0ca56a90 100644
--- a/apps/mobile-app/scripts/utils/routes.mjs
+++ b/apps/mobile-app/scripts/utils/routes.mjs
@@ -38,6 +38,12 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default,
},
+ {
+ key: 'AlphaContentCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories')
+ .default,
+ },
{
key: 'AlphaSelect',
getComponent: () =>
@@ -195,6 +201,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
},
+ {
+ key: 'DataCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
+ },
{
key: 'DateInput',
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,
@@ -341,10 +352,19 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
+ {
+ key: 'MediaCard',
+ getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
+ },
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
},
+ {
+ key: 'MessagingCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
+ },
{
key: 'ModalBackButton',
getComponent: () =>
diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts
index 24168f023..8be0ccc74 100644
--- a/apps/mobile-app/src/routes.ts
+++ b/apps/mobile-app/src/routes.ts
@@ -38,6 +38,12 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default,
},
+ {
+ key: 'AlphaContentCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories')
+ .default,
+ },
{
key: 'AlphaSelect',
getComponent: () =>
@@ -200,6 +206,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
},
+ {
+ key: 'DataCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
+ },
{
key: 'DateInput',
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,
@@ -346,10 +357,19 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
+ {
+ key: 'MediaCard',
+ getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
+ },
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
},
+ {
+ key: 'MessagingCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
+ },
{
key: 'ModalBackButton',
getComponent: () =>
diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
index 21f6f2176..168db338f 100644
--- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
+++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx
@@ -8,18 +8,21 @@ import {
withTiming,
} from 'react-native-reanimated';
import type { ThemeVars } from '@coinbase/cds-common/core/theme';
-import { assets } from '@coinbase/cds-common/internal/data/assets';
+import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles';
import { prices } from '@coinbase/cds-common/internal/data/prices';
import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData';
import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
import { useTheme } from '@coinbase/cds-mobile';
+import { DataCard } from '@coinbase/cds-mobile/alpha/data-card/DataCard';
import { Button, IconButton } from '@coinbase/cds-mobile/buttons';
import { ListCell } from '@coinbase/cds-mobile/cells';
-import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
+import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
import { Box, type BoxBaseProps, HStack, VStack } from '@coinbase/cds-mobile/layout';
import { Avatar, RemoteImage } from '@coinbase/cds-mobile/media';
+import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation';
import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader';
import { Pressable } from '@coinbase/cds-mobile/system';
import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs';
@@ -38,7 +41,7 @@ import {
import { Area, DottedArea, type DottedAreaProps } from '../../area';
import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis';
-import { BarChart, type BarComponentProps, BarPlot } from '../../bar';
+import { type BarComponentProps, BarPlot } from '../../bar';
import { CartesianChart } from '../../CartesianChart';
import { useCartesianChartContext } from '../../ChartProvider';
import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector';
@@ -2281,6 +2284,106 @@ function TwoLineScrubberLabel() {
);
}
+function DataCardWithLineChart() {
+ const exampleThumbnail = (
+
+ );
+
+ const getLineChartSeries = () => [
+ {
+ id: 'price',
+ data: prices.slice(0, 30).map((price: string) => parseFloat(price)),
+ color: 'accentBoldBlue',
+ },
+ ];
+
+ const lineChartSeries = useMemo(() => getLineChartSeries(), []);
+ const lineChartSeries2 = useMemo(() => getLineChartSeries(), []);
+ const ref = useRef(null);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ }
+ title="Card with Line Chart"
+ >
+
+
+
+ );
+}
type ExampleItem = {
title: string;
@@ -2542,20 +2645,22 @@ function ExampleNavigator() {
title: 'Two-Line Scrubber Label',
component: ,
},
+ {
+ title: 'In DataCard',
+ component: ,
+ },
],
[theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50],
);
const currentExample = examples[currentIndex];
- const isFirstExample = currentIndex === 0;
- const isLastExample = currentIndex === examples.length - 1;
const handlePrevious = useCallback(() => {
- setCurrentIndex((prev) => Math.max(0, prev - 1));
- }, []);
+ setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length);
+ }, [examples.length]);
const handleNext = useCallback(() => {
- setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1));
+ setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length);
}, [examples.length]);
return (
@@ -2565,12 +2670,11 @@ function ExampleNavigator() {
-
+
{currentExample.title}
{currentIndex + 1} / {examples.length}
@@ -2579,7 +2683,6 @@ function ExampleNavigator() {
(({ children, renderAsPressable, ...props }, ref) => {
+ const Component = renderAsPressable ? Pressable : VStack;
+ return (
+
+ {children}
+
+ );
+ }),
+);
+
+ContentCard.displayName = 'ContentCard';
diff --git a/packages/mobile/src/alpha/content-card/ContentCardBody.tsx b/packages/mobile/src/alpha/content-card/ContentCardBody.tsx
new file mode 100644
index 000000000..0a642a9a8
--- /dev/null
+++ b/packages/mobile/src/alpha/content-card/ContentCardBody.tsx
@@ -0,0 +1,101 @@
+import React, { forwardRef, memo, useMemo } from 'react';
+import type { StyleProp, View, ViewStyle } from 'react-native';
+
+import { HStack } from '../../layout';
+import { Box, type BoxBaseProps } from '../../layout/Box';
+import { VStack, type VStackProps } from '../../layout/VStack';
+import { Text } from '../../typography';
+
+export type ContentCardBodyBaseProps = BoxBaseProps & {
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ media?: React.ReactNode;
+ mediaPlacement?: 'top' | 'bottom' | 'start' | 'end';
+ styles?: {
+ root: StyleProp;
+ contentContainer: StyleProp;
+ mediaContainer: StyleProp;
+ };
+};
+
+export type ContentCardBodyProps = ContentCardBodyBaseProps;
+
+export const ContentCardBody = memo(
+ forwardRef(
+ (
+ { title, description, media, mediaPlacement = 'top', style, styles, padding = 2, ...props },
+ ref,
+ ) => {
+ const hasMedia = !!media;
+ const isHorizontal = hasMedia && (mediaPlacement === 'start' || mediaPlacement === 'end');
+ const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start');
+
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const descriptionNode = useMemo(() => {
+ if (typeof description === 'string') {
+ return (
+
+ {description}
+
+ );
+ }
+ return description;
+ }, [description]);
+
+ const contentNode = useMemo(() => {
+ return (
+
+ {titleNode}
+ {descriptionNode}
+
+ );
+ }, [isHorizontal, styles?.contentContainer, titleNode, descriptionNode]);
+
+ const mediaNode = useMemo(() => {
+ if (!hasMedia) return null;
+ return (
+
+ {media}
+
+ );
+ }, [hasMedia, media, styles?.mediaContainer]);
+
+ return (
+
+ {isMediaFirst ? mediaNode : contentNode}
+ {isMediaFirst ? contentNode : mediaNode}
+
+ );
+ },
+ ),
+);
+
+ContentCardBody.displayName = 'ContentCardBody';
diff --git a/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx b/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx
new file mode 100644
index 000000000..db5f6e980
--- /dev/null
+++ b/packages/mobile/src/alpha/content-card/ContentCardFooter.tsx
@@ -0,0 +1,16 @@
+import React, { forwardRef, memo } from 'react';
+import type { View } from 'react-native';
+
+import { HStack, type HStackProps } from '../../layout/HStack';
+
+export type ContentCardFooterBaseProps = HStackProps;
+
+export type ContentCardFooterProps = ContentCardFooterBaseProps;
+
+export const ContentCardFooter = memo(
+ forwardRef(({ paddingX = 2, paddingBottom = 2, ...props }, ref) => {
+ return ;
+ }),
+);
+
+ContentCardFooter.displayName = 'ContentCardFooter';
diff --git a/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx b/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx
new file mode 100644
index 000000000..798f7c960
--- /dev/null
+++ b/packages/mobile/src/alpha/content-card/ContentCardHeader.tsx
@@ -0,0 +1,88 @@
+import React, { forwardRef, memo, useMemo } from 'react';
+import type { StyleProp, View, ViewStyle } from 'react-native';
+
+import { type BoxBaseProps } from '../../layout/Box';
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { Text } from '../../typography';
+
+export type ContentCardHeaderBaseProps = BoxBaseProps & {
+ title: React.ReactNode;
+ subtitle: React.ReactNode;
+ thumbnail: React.ReactNode;
+ action: React.ReactNode;
+ styles?: {
+ root: StyleProp;
+ contentContainer: StyleProp;
+ };
+};
+
+export type ContentCardHeaderProps = ContentCardHeaderBaseProps;
+
+export const ContentCardHeader = memo(
+ forwardRef(
+ (
+ {
+ title,
+ subtitle,
+ thumbnail,
+ styles,
+ action,
+ gap = 1.5,
+ paddingX = 2,
+ paddingTop = 2,
+ style,
+ ...props
+ },
+ ref,
+ ) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(() => {
+ if (typeof subtitle === 'string') {
+ return (
+
+ {subtitle}
+
+ );
+ }
+ return subtitle;
+ }, [subtitle]);
+
+ return (
+
+ {thumbnail}
+
+ {titleNode}
+ {subtitleNode}
+
+ {action}
+
+ );
+ },
+ ),
+);
+
+ContentCardHeader.displayName = 'ContentCardHeader';
diff --git a/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx b/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx
new file mode 100644
index 000000000..acc86a93d
--- /dev/null
+++ b/packages/mobile/src/alpha/content-card/__stories__/AlphaContentCard.stories.tsx
@@ -0,0 +1,395 @@
+import { Alert } from 'react-native';
+import { ethBackground } from '@coinbase/cds-common/internal/data/assets';
+
+import { Button, IconButton } from '../../../buttons';
+import { Example, ExampleScreen } from '../../../examples/ExampleScreen';
+import { HStack, VStack } from '../../../layout';
+import { RemoteImage } from '../../../media';
+import { ContentCard, ContentCardBody, ContentCardFooter, ContentCardHeader } from '../index';
+
+const exampleThumbnail = (
+
+);
+
+const exampleMedia = (
+
+);
+
+const exampleMediaSquare = (
+
+);
+
+const ContentCardScreen = () => {
+ return (
+
+
+
+
+
+
+
+
+ }
+ subtitle="transparent"
+ thumbnail={exampleThumbnail}
+ title="Content Card"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="bgAlternate"
+ thumbnail={exampleThumbnail}
+ title="Content Card"
+ />
+
+
+
+
+
+
+
+
+
+
+ Alert.alert('Card clicked!')} width={327}>
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+ }
+ mediaPlacement="top"
+ title="No Media Card"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle={
+
+ }
+ thumbnail={exampleThumbnail}
+ title={
+
+ }
+ />
+
+
+
+
+ }
+ media={exampleMedia}
+ mediaPlacement="top"
+ title={
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+ Alert.alert('Card 3 clicked!')} width={327}>
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="This is a very long subtitle text that demonstrates how the header handles longer content"
+ thumbnail={exampleThumbnail}
+ title="This is a very long title text that demonstrates how the header handles longer content"
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ContentCardScreen;
diff --git a/packages/mobile/src/alpha/content-card/index.tsx b/packages/mobile/src/alpha/content-card/index.tsx
new file mode 100644
index 000000000..4f5303cb0
--- /dev/null
+++ b/packages/mobile/src/alpha/content-card/index.tsx
@@ -0,0 +1,4 @@
+export * from './ContentCard';
+export * from './ContentCardBody';
+export * from './ContentCardFooter';
+export * from './ContentCardHeader';
diff --git a/packages/mobile/src/alpha/data-card/DataCard.tsx b/packages/mobile/src/alpha/data-card/DataCard.tsx
new file mode 100644
index 000000000..baa86edb7
--- /dev/null
+++ b/packages/mobile/src/alpha/data-card/DataCard.tsx
@@ -0,0 +1,61 @@
+import { forwardRef, memo } from 'react';
+import type { View } from 'react-native';
+import type { ThemeVars } from '@coinbase/cds-common';
+
+import { CardRoot, type CardRootProps } from '../../cards/CardRoot';
+
+import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout';
+
+export type DataCardBaseProps = DataCardLayoutProps & {
+ styles?: {
+ root?: React.CSSProperties;
+ };
+};
+
+export type DataCardProps = Omit & DataCardBaseProps;
+
+const dataCardContainerProps = {
+ borderRadius: 500 as ThemeVars.BorderRadius,
+ background: 'bgAlternate' as ThemeVars.Color,
+ overflow: 'hidden' as const,
+};
+
+export const DataCard = memo(
+ forwardRef(
+ (
+ {
+ title,
+ subtitle,
+ tag,
+ thumbnail,
+ children,
+ layout,
+ renderAsPressable,
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ ...props
+ },
+ ref,
+ ) => (
+
+
+ {children}
+
+
+ ),
+ ),
+);
+
+DataCard.displayName = 'DataCard';
diff --git a/packages/mobile/src/alpha/data-card/DataCardLayout.tsx b/packages/mobile/src/alpha/data-card/DataCardLayout.tsx
new file mode 100644
index 000000000..1d6a16bf7
--- /dev/null
+++ b/packages/mobile/src/alpha/data-card/DataCardLayout.tsx
@@ -0,0 +1,116 @@
+import React, { memo, useMemo } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+
+import { Box } from '../../layout/Box';
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { Tag } from '../../tag/Tag';
+import { Text } from '../../typography';
+
+export type DataCardLayoutBaseProps = {
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
+ title: React.ReactNode;
+ /** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
+ tag?: React.ReactNode;
+ /** React node to display as a thumbnail in the header area. */
+ thumbnail?: React.ReactNode;
+ /** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. */
+ layout: 'horizontal' | 'vertical';
+ /** child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */
+ children?: React.ReactNode;
+};
+
+export type DataCardLayoutProps = DataCardLayoutBaseProps & {
+ styles?: {
+ layoutContainer?: StyleProp;
+ headerContainer?: StyleProp;
+ headerContent?: StyleProp;
+ titleContainer?: StyleProp;
+ };
+};
+export const DataCardLayout = memo(
+ ({
+ title,
+ subtitle,
+ tag,
+ thumbnail,
+ layout = 'vertical',
+ children,
+ styles = {},
+ }: DataCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(() => {
+ if (typeof subtitle === 'string') {
+ return (
+
+ {subtitle}
+
+ );
+ }
+ return subtitle;
+ }, [subtitle]);
+
+ const tagNode = useMemo(() => {
+ if (typeof tag === 'string') return {tag};
+ return tag;
+ }, [tag]);
+
+ const layoutContainerSpacingProps = useMemo(() => {
+ return {
+ flexDirection: layout === 'horizontal' ? ('row' as const) : ('column' as const),
+ gap: layout === 'horizontal' ? 2 : 1,
+ padding: 2,
+ } as const;
+ }, [layout]);
+
+ const headerSpacingProps = useMemo(() => {
+ return {
+ flexDirection: layout === 'horizontal' ? ('column' as const) : ('row' as const),
+ gap: layout === 'horizontal' ? 2 : 1,
+ alignItems: layout === 'horizontal' ? ('flex-start' as const) : ('center' as const),
+ } as const;
+ }, [layout]);
+
+ return (
+
+
+ {thumbnail}
+
+
+ {tagNode}
+ {titleNode}
+
+ {subtitleNode}
+
+
+ {children}
+
+ );
+ },
+);
+
+DataCardLayout.displayName = 'DataCardLayout';
diff --git a/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx
new file mode 100644
index 000000000..88fbcf66f
--- /dev/null
+++ b/packages/mobile/src/alpha/data-card/__stories__/DataCard.stories.tsx
@@ -0,0 +1,238 @@
+import { useRef } from 'react';
+import { Alert, type View } from 'react-native';
+import { ethBackground } from '@coinbase/cds-common/internal/data/assets';
+import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
+
+import { Example, ExampleScreen } from '../../../examples/ExampleScreen';
+import { HStack } from '../../../layout';
+import { Box } from '../../../layout/Box';
+import { VStack } from '../../../layout/VStack';
+import { RemoteImage } from '../../../media';
+import { ProgressBar, ProgressBarWithFixedLabels, ProgressCircle } from '../../../visualizations';
+import { DataCard } from '../DataCard';
+
+const exampleThumbnail = (
+
+);
+
+const DataCardScreen = () => {
+ const ref1 = useRef(null);
+ const ref2 = useRef(null);
+
+ return (
+
+ {/* Basic Examples */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Features */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Interactive */}
+
+
+ Alert.alert('Progress bar card clicked!')}
+ subtitle="Clickable progress card"
+ tag="Action"
+ thumbnail={exampleThumbnail}
+ title="Actionable Progress Bar"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Style Overrides */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Multiple Cards */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DataCardScreen;
diff --git a/packages/mobile/src/cards/CardMedia.tsx b/packages/mobile/src/cards/CardMedia.tsx
index 08865306a..d6da9f053 100644
--- a/packages/mobile/src/cards/CardMedia.tsx
+++ b/packages/mobile/src/cards/CardMedia.tsx
@@ -1,4 +1,4 @@
-import React, { memo } from 'react';
+import { memo } from 'react';
import {
defaultMediaDimension,
defaultMediaSize,
@@ -11,8 +11,7 @@ import type {
} from '@coinbase/cds-common/types';
import { Pictogram, SpotSquare } from '../illustrations';
-
-import { CardRemoteImage } from './CardRemoteImage';
+import { getSource, RemoteImage } from '../media/RemoteImage';
export type CardMediaProps = CommonCardMediaProps;
@@ -50,8 +49,10 @@ export const CardMedia = memo(function CardMedia({ placement = 'end', ...props }
);
case 'image':
return (
- getSource(src), [src]);
- return ;
-});
-
-CardRemoteImage.displayName = 'CardRemoteImage';
diff --git a/packages/mobile/src/cards/CardRoot.tsx b/packages/mobile/src/cards/CardRoot.tsx
new file mode 100644
index 000000000..32105f357
--- /dev/null
+++ b/packages/mobile/src/cards/CardRoot.tsx
@@ -0,0 +1,25 @@
+import React, { forwardRef, memo } from 'react';
+import type { View } from 'react-native';
+
+import { HStack } from '../layout/HStack';
+import { Pressable, type PressableProps } from '../system/Pressable';
+
+export type CardRootBaseProps = {
+ children: React.ReactNode;
+ renderAsPressable?: boolean;
+};
+
+export type CardRootProps = CardRootBaseProps & PressableProps;
+
+export const CardRoot = memo(
+ forwardRef(({ children, renderAsPressable, ...props }, ref) => {
+ const Component = renderAsPressable ? Pressable : HStack;
+ return (
+
+ {children}
+
+ );
+ }),
+);
+
+CardRoot.displayName = 'CardRoot';
diff --git a/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx b/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx
new file mode 100644
index 000000000..a6584e286
--- /dev/null
+++ b/packages/mobile/src/cards/MediaCard/MediaCardLayout.tsx
@@ -0,0 +1,126 @@
+import React, { memo, useMemo } from 'react';
+import type { StyleProp, ViewStyle } from 'react-native';
+
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { Text } from '../../typography/Text';
+
+export type MediaCardLayoutBaseProps = {
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
+ title?: React.ReactNode;
+ /** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component. */
+ description?: React.ReactNode;
+ /** React node to display as a thumbnail in the content area. */
+ thumbnail: React.ReactNode;
+ /** React node to display as the main media content. When provided, it will be rendered in an HStack container taking up 50% of the card width. */
+ media?: React.ReactNode;
+ /** The position of the media within the card. */
+ mediaPlacement?: 'start' | 'end';
+};
+
+export type MediaCardLayoutProps = MediaCardLayoutBaseProps & {
+ styles?: {
+ layoutContainer?: StyleProp;
+ contentContainer?: StyleProp;
+ textContainer?: StyleProp;
+ headerContainer?: StyleProp;
+ mediaContainer?: StyleProp;
+ };
+};
+
+const MediaCardLayout = memo(
+ ({
+ title,
+ subtitle,
+ description,
+ thumbnail,
+ media,
+ mediaPlacement = 'end',
+ styles = {},
+ }: MediaCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(
+ () =>
+ typeof subtitle === 'string' ? (
+
+ {subtitle}
+
+ ) : (
+ subtitle
+ ),
+ [subtitle],
+ );
+
+ const headerNode = useMemo(
+ () => (
+
+ {subtitleNode}
+ {titleNode}
+
+ ),
+ [subtitleNode, titleNode, styles?.headerContainer],
+ );
+
+ const descriptionNode = useMemo(
+ () =>
+ typeof description === 'string' ? (
+
+ {description}
+
+ ) : (
+ description
+ ),
+ [description],
+ );
+
+ const contentNode = useMemo(
+ () => (
+
+ {thumbnail}
+
+ {headerNode}
+ {descriptionNode}
+
+
+ ),
+ [styles?.contentContainer, styles?.textContainer, thumbnail, headerNode, descriptionNode],
+ );
+
+ const mediaNode = useMemo(() => {
+ if (media) {
+ return (
+
+ {media}
+
+ );
+ }
+ }, [media, styles?.mediaContainer]);
+
+ return (
+
+ {mediaPlacement === 'start' ? mediaNode : contentNode}
+ {mediaPlacement === 'end' ? mediaNode : contentNode}
+
+ );
+ },
+);
+
+export { MediaCardLayout };
diff --git a/packages/mobile/src/cards/MediaCard/index.tsx b/packages/mobile/src/cards/MediaCard/index.tsx
new file mode 100644
index 000000000..5af40e169
--- /dev/null
+++ b/packages/mobile/src/cards/MediaCard/index.tsx
@@ -0,0 +1,56 @@
+import { forwardRef, memo, useCallback, useMemo } from 'react';
+import type { PressableStateCallbackType, StyleProp, View, ViewStyle } from 'react-native';
+import type { ThemeVars } from '@coinbase/cds-common';
+
+import { CardRoot, type CardRootProps } from '../CardRoot';
+
+import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout';
+
+export type MediaCardBaseProps = MediaCardLayoutProps;
+
+export type MediaCardProps = Omit &
+ MediaCardBaseProps & {
+ styles?: {
+ root?: StyleProp | ((state: PressableStateCallbackType) => StyleProp);
+ };
+ };
+
+const mediaCardContainerProps = {
+ borderRadius: 500 as ThemeVars.BorderRadius,
+ background: 'bgAlternate' as ThemeVars.Color,
+ overflow: 'hidden' as const,
+};
+
+export const MediaCard = memo(
+ forwardRef(
+ (
+ {
+ title,
+ subtitle,
+ description,
+ thumbnail,
+ media,
+ mediaPlacement = 'end',
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+
+
+
+ );
+ },
+ ),
+);
+
+MediaCard.displayName = 'MediaCard';
diff --git a/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx b/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx
new file mode 100644
index 000000000..52164bf6d
--- /dev/null
+++ b/packages/mobile/src/cards/MesssagingCard/MessagingCardLayout.tsx
@@ -0,0 +1,187 @@
+import React, { memo, useMemo } from 'react';
+import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native';
+
+import { IconButton } from '../../buttons/IconButton';
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { Tag } from '../../tag/Tag';
+import { Text } from '../../typography/Text';
+
+export type MessagingCardLayoutProps = {
+ /** Type of messaging card. Determines background color and text color. */
+ type: 'upsell' | 'nudge';
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component with appropriate color based on type. */
+ title?: React.ReactNode;
+ /** Text or React node to display as the card subtitle. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component with appropriate color based on type. */
+ description?: React.ReactNode;
+ /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
+ tag?: React.ReactNode;
+ /** React node to display as actions (typically buttons) at the bottom of the content area. */
+ actions?: React.ReactNode;
+ /** React node to display as the dismiss button. When provided, a dismiss button will be rendered in the top-right corner. */
+ dismissButton?: React.ReactNode;
+ /** Callback fired when the dismiss button is pressed. When provided, a dismiss button will be rendered in the top-right corner. */
+ onDismiss?: (event: GestureResponderEvent) => void;
+ /** Accessibility label for the dismiss button.
+ * @default 'Dismiss card'
+ */
+ dismissButtonAccessibilityLabel?: string;
+ /** Placement of the media content relative to the text content. */
+ mediaPlacement: 'start' | 'end';
+ /** React node to display as the main media content. When provided, it will be rendered in an HStack container. */
+ media?: React.ReactNode;
+ styles?: {
+ layoutContainer?: StyleProp;
+ contentContainer?: StyleProp;
+ textContainer?: StyleProp;
+ mediaContainer?: StyleProp;
+ dismissButtonContainer?: StyleProp;
+ };
+};
+
+export const MessagingCardLayout = memo(
+ ({
+ type,
+ title,
+ description,
+ tag,
+ actions,
+ onDismiss,
+ dismissButtonAccessibilityLabel = 'Dismiss card',
+ mediaPlacement = 'end',
+ media,
+ styles = {},
+ dismissButton,
+ }: MessagingCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title, type]);
+
+ const descriptionNode = useMemo(() => {
+ if (typeof description === 'string') {
+ return (
+
+ {description}
+
+ );
+ }
+ return description;
+ }, [description, type]);
+
+ const tagNode = useMemo(() => {
+ if (typeof tag === 'string') {
+ return {tag};
+ }
+ return tag;
+ }, [tag]);
+
+ const dismissButtonNode = useMemo(() => {
+ if (dismissButton) {
+ return dismissButton;
+ }
+ if (onDismiss) {
+ const handleDismiss = (event: GestureResponderEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onDismiss(event);
+ };
+
+ return (
+
+
+
+ );
+ }
+ return null;
+ }, [dismissButton, dismissButtonAccessibilityLabel, onDismiss, styles?.dismissButtonContainer]);
+
+ const contentContainerPaddingProps = useMemo(() => {
+ if (mediaPlacement === 'start' && onDismiss) {
+ // needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button
+ // this is to avoid dismiss button from overlapping with the content area
+ return {
+ paddingY: 2,
+ paddingStart: 2,
+ paddingEnd: 6,
+ } as const;
+ }
+ return {
+ padding: 2,
+ } as const;
+ }, [mediaPlacement, onDismiss]);
+
+ const mediaContainerPaddingProps = useMemo(() => {
+ if (type === 'upsell') return;
+ if (mediaPlacement === 'start') {
+ return { paddingStart: 3, paddingEnd: 1 } as const;
+ }
+ // when media is placed at the end, we need to add additional padding to the end of the media container
+ // this is to avoid the dismiss button from overlapping with the media
+ return onDismiss
+ ? ({ paddingStart: 1, paddingEnd: 6 } as const)
+ : ({ paddingStart: 1, paddingEnd: 3 } as const);
+ }, [mediaPlacement, onDismiss, type]);
+
+ return (
+
+
+
+ {tagNode}
+ {titleNode}
+ {descriptionNode}
+
+ {actions}
+
+ {media && (
+
+ {media}
+
+ )}
+ {dismissButtonNode}
+
+ );
+ },
+);
+
+MessagingCardLayout.displayName = 'MessagingCardLayout';
diff --git a/packages/mobile/src/cards/MesssagingCard/index.tsx b/packages/mobile/src/cards/MesssagingCard/index.tsx
new file mode 100644
index 000000000..1ba982966
--- /dev/null
+++ b/packages/mobile/src/cards/MesssagingCard/index.tsx
@@ -0,0 +1,71 @@
+import { forwardRef, memo } from 'react';
+import type { StyleProp, View, ViewStyle } from 'react-native';
+import type { ThemeVars } from '@coinbase/cds-common';
+
+import { CardRoot, type CardRootProps } from '../CardRoot';
+
+import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout';
+
+export type MessagingCardBaseProps = MessagingCardLayoutProps;
+
+export type MessagingCardProps = Omit &
+ MessagingCardBaseProps & {
+ styles?: {
+ root?: StyleProp;
+ };
+ };
+
+const messagingCardContainerProps = {
+ borderRadius: 500 as ThemeVars.BorderRadius,
+ overflow: 'hidden' as const,
+};
+
+export const MessagingCard = memo(
+ forwardRef(
+ (
+ {
+ type,
+ title,
+ description,
+ tag,
+ actions,
+ dismissButton,
+ onDismiss,
+ dismissButtonAccessibilityLabel,
+ mediaPlacement,
+ media,
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ ...props
+ },
+ ref,
+ ) => {
+ const background = type === 'upsell' ? 'bgPrimary' : 'bgAlternate';
+ return (
+
+
+
+ );
+ },
+ ),
+);
+
+MessagingCard.displayName = 'MessagingCard';
diff --git a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx
new file mode 100644
index 000000000..4a78cebe8
--- /dev/null
+++ b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx
@@ -0,0 +1,184 @@
+import { useRef } from 'react';
+import { type View } from 'react-native';
+import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
+import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
+
+import { Carousel } from '../../carousel/Carousel';
+import { CarouselItem } from '../../carousel/CarouselItem';
+import { Example, ExampleScreen } from '../../examples/ExampleScreen';
+import { RemoteImage } from '../../media/RemoteImage';
+import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography';
+import { Text } from '../../typography/Text';
+import type { MediaCardProps } from '../MediaCard';
+import { MediaCard } from '../MediaCard';
+
+const exampleProps: Omit = {
+ title: 'Title',
+ subtitle: 'Subtitle',
+ description: 'Description',
+ width: 320,
+};
+
+const exampleThumbnail = (
+
+);
+
+const exampleMedia = (
+
+);
+
+const MediaCardScreen = () => {
+ const ref = useRef(null);
+ return (
+
+ {/* Basic Examples */}
+
+
+
+
+
+
+
+
+ {/* Media Placement */}
+
+
+
+
+
+
+
+
+ {/* Text Content */}
+
+
+
+
+
+
+ Custom description with bold text and{' '}
+ italic text
+
+ }
+ media={exampleMedia}
+ subtitle={Custom Subtitle}
+ thumbnail={exampleThumbnail}
+ title={Custom Title}
+ width={320}
+ />
+
+
+ {/* Styling */}
+
+
+
+
+
+
+
+
+ {/* Interactive */}
+
+ console.log('Card clicked!')}
+ subtitle="Button"
+ thumbnail={exampleThumbnail}
+ title="Interactive Card"
+ width={320}
+ />
+
+
+ {/* Multiple Cards */}
+
+
+
+
+
+
+
+ }
+ title="Bitcoin"
+ width={320}
+ />
+
+
+
+
+
+
+
+ );
+};
+
+export default MediaCardScreen;
diff --git a/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx
new file mode 100644
index 000000000..e0c917f64
--- /dev/null
+++ b/packages/mobile/src/cards/__stories__/MessagingCard.stories.tsx
@@ -0,0 +1,347 @@
+import { useRef } from 'react';
+import { Alert, type View } from 'react-native';
+import { StyleSheet } from 'react-native';
+import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets';
+import { NoopFn } from '@coinbase/cds-common/utils/mockUtils';
+
+import { Button } from '../../buttons/Button';
+import { IconButton } from '../../buttons/IconButton';
+import { Carousel } from '../../carousel/Carousel';
+import { CarouselItem } from '../../carousel/CarouselItem';
+import { Example, ExampleScreen } from '../../examples/ExampleScreen';
+import { Pictogram } from '../../illustrations';
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { RemoteImage } from '../../media/RemoteImage';
+import { Text } from '../../typography/Text';
+import type { MessagingCardProps } from '../MesssagingCard';
+import { MessagingCard } from '../MesssagingCard';
+
+const exampleProps: MessagingCardProps = {
+ title: 'Title',
+ description: 'Description',
+ mediaPlacement: 'end',
+ type: 'nudge',
+} as const;
+
+const styles = StyleSheet.create({
+ image: {
+ width: 130,
+ height: 174,
+ position: 'relative',
+ },
+});
+
+const MessagingCardScreen = () => {
+ const ref = useRef(null);
+ return (
+
+ {/* Basic Types */}
+
+
+
+ }
+ mediaPlacement="end"
+ title="Upsell Card"
+ type="upsell"
+ />
+
+ }
+ mediaPlacement="start"
+ title="Upsell Card"
+ type="upsell"
+ />
+ }
+ mediaPlacement="end"
+ title="Nudge Card"
+ type="nudge"
+ />
+ }
+ mediaPlacement="start"
+ title="Nudge Card"
+ type="nudge"
+ />
+
+
+
+ {/* Features */}
+
+
+
+ }
+ mediaPlacement="end"
+ onDismiss={() => Alert.alert('Card dismissed!')}
+ title="Dismissible Card"
+ type="upsell"
+ />
+
+ }
+ mediaPlacement="end"
+ tag="New"
+ title="Tagged Card"
+ type="upsell"
+ />
+
+ Action
+
+ }
+ description="Upsell card with action button"
+ media={
+
+ }
+ mediaPlacement="end"
+ title="Upsell with Action"
+ type="upsell"
+ />
+
+ Get Started
+
+ }
+ description="Complete upsell card with all features"
+ dismissButtonAccessibilityLabel="Dismiss"
+ media={
+
+ }
+ mediaPlacement="end"
+ onDismiss={() => Alert.alert('Dismissed')}
+ tag="New"
+ title="Complete Upsell Card"
+ type="upsell"
+ />
+
+ Alert.alert('Custom dismiss pressed!')}
+ variant="secondary"
+ />
+
+ }
+ media={
+
+ }
+ mediaPlacement="end"
+ title="Custom Dismiss Button"
+ type="upsell"
+ />
+
+
+
+ {/* Interactive */}
+
+
+ }
+ mediaPlacement="end"
+ onPress={NoopFn}
+ title="Interactive Card"
+ type="upsell"
+ />
+
+
+ {/* Text Content */}
+
+
+
+ }
+ mediaPlacement="end"
+ title="This is a very long title text that demonstrates text wrapping"
+ type="upsell"
+ />
+
+ Custom description with bold text and{' '}
+ italic text
+
+ }
+ media={
+
+ }
+ mediaPlacement="end"
+ tag={Custom Tag}
+ title={Custom Title}
+ type="upsell"
+ />
+
+
+
+
+
+
+
+ }
+ mediaPlacement="end"
+ title="Card 1"
+ type="upsell"
+ width={320}
+ />
+
+
+ }
+ mediaPlacement="end"
+ onPress={NoopFn}
+ tag="Link"
+ title="Card 2"
+ type="nudge"
+ width={320}
+ />
+
+
+
+ }
+ mediaPlacement="end"
+ onPress={() => console.log('clicked')}
+ tag="Action"
+ title="Card 3"
+ type="upsell"
+ width={320}
+ />
+
+
+
+
+ );
+};
+
+export default MessagingCardScreen;
diff --git a/packages/mobile/src/cards/index.ts b/packages/mobile/src/cards/index.ts
index bfa55a19f..df1a81b90 100644
--- a/packages/mobile/src/cards/index.ts
+++ b/packages/mobile/src/cards/index.ts
@@ -4,9 +4,16 @@ export * from './CardFooter';
export * from './CardGroup';
export * from './CardHeader';
export * from './CardMedia';
+export * from './CardRoot';
// Card variants
export * from './AnnouncementCard';
export * from './FeatureEntryCard';
export * from './FeedCard';
// Phoenix cards
export * from './ContentCard';
+// Media card
+export * from './MediaCard';
+// Messaging card
+export * from './MesssagingCard';
+// Data card
+export * from './DataCard';
diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts
index d5d4c026b..b0ca56a90 100644
--- a/packages/ui-mobile-playground/src/routes.ts
+++ b/packages/ui-mobile-playground/src/routes.ts
@@ -38,6 +38,12 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default,
},
+ {
+ key: 'AlphaContentCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories')
+ .default,
+ },
{
key: 'AlphaSelect',
getComponent: () =>
@@ -195,6 +201,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
},
+ {
+ key: 'DataCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
+ },
{
key: 'DateInput',
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,
@@ -341,10 +352,19 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
+ {
+ key: 'MediaCard',
+ getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
+ },
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
},
+ {
+ key: 'MessagingCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
+ },
{
key: 'ModalBackButton',
getComponent: () =>
diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts
index d5d4c026b..b0ca56a90 100644
--- a/packages/ui-mobile-visreg/src/routes.ts
+++ b/packages/ui-mobile-visreg/src/routes.ts
@@ -38,6 +38,12 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default,
},
+ {
+ key: 'AlphaContentCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/content-card/__stories__/AlphaContentCard.stories')
+ .default,
+ },
{
key: 'AlphaSelect',
getComponent: () =>
@@ -195,6 +201,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default,
},
+ {
+ key: 'DataCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default,
+ },
{
key: 'DateInput',
getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default,
@@ -341,10 +352,19 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default,
},
+ {
+ key: 'MediaCard',
+ getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default,
+ },
{
key: 'MediaChip',
getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default,
},
+ {
+ key: 'MessagingCard',
+ getComponent: () =>
+ require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default,
+ },
{
key: 'ModalBackButton',
getComponent: () =>
diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx
index 12ded1762..bc4670b7c 100644
--- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx
+++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx
@@ -1,10 +1,11 @@
import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
-import { assets } from '@coinbase/cds-common/internal/data/assets';
+import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles';
import { prices } from '@coinbase/cds-common/internal/data/prices';
import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData';
import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext';
import type { TabValue } from '@coinbase/cds-common/tabs/useTabs';
+import { DataCard } from '@coinbase/cds-web/alpha/data-card/DataCard';
import { ListCell } from '@coinbase/cds-web/cells';
import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints';
import { Box, HStack, VStack } from '@coinbase/cds-web/layout';
@@ -1934,6 +1935,115 @@ export const All = () => {
+
+
+
);
};
+
+function DataCardWithLineChart() {
+ const exampleThumbnail = (
+
+ );
+
+ const getLineChartSeries = () => [
+ {
+ id: 'price',
+ data: prices.slice(0, 30).map((price: string) => parseFloat(price)),
+ color: 'var(--color-accentBoldBlue)',
+ },
+ ];
+
+ const lineChartSeries = useMemo(() => getLineChartSeries(), []);
+ const lineChartSeries2 = useMemo(() => getLineChartSeries(), []);
+ const ref = useRef(null);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ title="Card with Line Chart"
+ >
+
+
+
+ );
+}
diff --git a/packages/web/src/alpha/content-card/ContentCard.tsx b/packages/web/src/alpha/content-card/ContentCard.tsx
new file mode 100644
index 000000000..ae8201c9f
--- /dev/null
+++ b/packages/web/src/alpha/content-card/ContentCard.tsx
@@ -0,0 +1,69 @@
+import React, { forwardRef, memo } from 'react';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { VStack } from '../../layout/VStack';
+import { Pressable, type PressableBaseProps } from '../../system/Pressable';
+
+export type ContentCardBaseProps = Polymorphic.ExtendableProps<
+ PressableBaseProps,
+ {
+ children?: React.ReactNode;
+ renderAsPressable?: boolean;
+ }
+>;
+
+export type ContentCardProps = Polymorphic.Props<
+ AsComponent,
+ ContentCardBaseProps
+>;
+
+type ContentCardComponent = ((
+ props: Polymorphic.Props,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const ContentCard: ContentCardComponent = memo(
+ forwardRef, ContentCardBaseProps>(
+ (
+ {
+ renderAsPressable,
+ children,
+ background = 'bg',
+ borderRadius = 500,
+ ...props
+ }: ContentCardProps,
+ ref?: Polymorphic.Ref,
+ ) => {
+ if (renderAsPressable) {
+ const { as, ...pressableRestProps } = props;
+ return (
+
+ {children}
+
+ );
+ } else {
+ const { as, ...vstackRestProps } = props;
+ return (
+
+ {children}
+
+ );
+ }
+ },
+ ),
+);
+
+ContentCard.displayName = 'ContentCard';
diff --git a/packages/web/src/alpha/content-card/ContentCardBody.tsx b/packages/web/src/alpha/content-card/ContentCardBody.tsx
new file mode 100644
index 000000000..f575a80e8
--- /dev/null
+++ b/packages/web/src/alpha/content-card/ContentCardBody.tsx
@@ -0,0 +1,137 @@
+import { forwardRef, memo, useMemo } from 'react';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { cx } from '../../cx';
+import { Box, type BoxBaseProps, VStack } from '../../layout';
+import { Text } from '../../typography';
+
+export type ContentCardBodyBaseProps = Polymorphic.ExtendableProps<
+ BoxBaseProps,
+ {
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ media?: React.ReactNode;
+ mediaPlacement?: 'top' | 'bottom' | 'start' | 'end';
+ styles?: {
+ root: React.CSSProperties;
+ contentContainer: React.CSSProperties;
+ mediaContainer: React.CSSProperties;
+ };
+ classNames?: {
+ root: string;
+ contentContainer: string;
+ mediaContainer: string;
+ };
+ }
+>;
+
+export type ContentCardBodyProps = Polymorphic.Props<
+ AsComponent,
+ ContentCardBodyBaseProps
+>;
+
+type ContentCardBodyComponent = ((
+ props: ContentCardBodyProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const ContentCardBody: ContentCardBodyComponent = memo(
+ forwardRef, ContentCardBodyBaseProps>(
+ (
+ {
+ as,
+ title,
+ description,
+ media,
+ mediaPlacement = 'top',
+ style,
+ styles,
+ classNames,
+ className,
+ padding = 2,
+ ...props
+ }: ContentCardBodyProps,
+ ref?: Polymorphic.Ref,
+ ) => {
+ const Component = (as ?? 'div') satisfies React.ElementType;
+ const hasMedia = !!media;
+ const isHorizontal = hasMedia && (mediaPlacement === 'start' || mediaPlacement === 'end');
+ const isMediaFirst = hasMedia && (mediaPlacement === 'top' || mediaPlacement === 'start');
+
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const descriptionNode = useMemo(() => {
+ if (typeof description === 'string') {
+ return (
+
+ {description}
+
+ );
+ }
+ return description;
+ }, [description]);
+
+ const contentNode = useMemo(() => {
+ return (
+
+ {titleNode}
+ {descriptionNode}
+
+ );
+ }, [
+ isHorizontal,
+ classNames?.contentContainer,
+ styles?.contentContainer,
+ titleNode,
+ descriptionNode,
+ ]);
+
+ const mediaNode = useMemo(() => {
+ if (!hasMedia) return null;
+ return (
+
+ {media}
+
+ );
+ }, [hasMedia, media, classNames?.mediaContainer, styles?.mediaContainer]);
+
+ return (
+
+ {isMediaFirst ? mediaNode : contentNode}
+ {isMediaFirst ? contentNode : mediaNode}
+
+ );
+ },
+ ),
+);
+
+ContentCardBody.displayName = 'ContentCardBody';
diff --git a/packages/web/src/alpha/content-card/ContentCardFooter.tsx b/packages/web/src/alpha/content-card/ContentCardFooter.tsx
new file mode 100644
index 000000000..f4bc7b9cc
--- /dev/null
+++ b/packages/web/src/alpha/content-card/ContentCardFooter.tsx
@@ -0,0 +1,36 @@
+import { forwardRef, memo } from 'react';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { HStack, type HStackBaseProps } from '../../layout';
+
+export type ContentCardFooterBaseProps = HStackBaseProps;
+
+export type ContentCardFooterProps = Polymorphic.Props<
+ AsComponent,
+ ContentCardFooterBaseProps
+>;
+
+type ContentCardFooterComponent = ((
+ props: ContentCardFooterProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const ContentCardFooter: ContentCardFooterComponent = memo(
+ forwardRef, ContentCardFooterBaseProps>(
+ (
+ { as, paddingX = 2, paddingBottom = 2, ...props }: ContentCardFooterProps,
+ ref?: Polymorphic.Ref,
+ ) => {
+ const Component = (as ?? 'footer') satisfies React.ElementType;
+ return (
+
+ );
+ },
+ ),
+);
diff --git a/packages/web/src/alpha/content-card/ContentCardHeader.tsx b/packages/web/src/alpha/content-card/ContentCardHeader.tsx
new file mode 100644
index 000000000..0b2616cf7
--- /dev/null
+++ b/packages/web/src/alpha/content-card/ContentCardHeader.tsx
@@ -0,0 +1,107 @@
+import React, { forwardRef, memo, useMemo } from 'react';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { cx } from '../../cx';
+import { type BoxBaseProps, HStack, VStack, type VStackBaseProps } from '../../layout';
+import { Text } from '../../typography';
+
+export type ContentCardHeaderBaseProps = Polymorphic.ExtendableProps<
+ BoxBaseProps,
+ {
+ title: React.ReactNode;
+ subtitle: React.ReactNode;
+ thumbnail: React.ReactNode;
+ action: React.ReactNode;
+ styles?: {
+ root: React.CSSProperties;
+ contentContainer: React.CSSProperties;
+ };
+ classNames?: {
+ root: string;
+ contentContainer: string;
+ };
+ }
+>;
+
+export type ContentCardHeaderProps = Polymorphic.Props<
+ AsComponent,
+ ContentCardHeaderBaseProps
+>;
+
+type ContentCardHeaderComponent = ((
+ props: ContentCardHeaderProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const ContentCardHeader: ContentCardHeaderComponent = memo(
+ forwardRef, ContentCardHeaderBaseProps>(
+ (
+ {
+ as,
+ title,
+ subtitle,
+ thumbnail,
+ action,
+ gap = 1.5,
+ paddingX = 2,
+ paddingTop = 2,
+ styles,
+ style,
+ classNames,
+ className,
+ ...props
+ }: ContentCardHeaderProps,
+ ref?: Polymorphic.Ref,
+ ) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(() => {
+ if (typeof subtitle === 'string') {
+ return (
+
+ {subtitle}
+
+ );
+ }
+ return subtitle;
+ }, [subtitle]);
+
+ const Component = (as ?? 'header') satisfies React.ElementType;
+
+ return (
+
+ {thumbnail}
+
+ {titleNode}
+ {subtitleNode}
+
+ {action}
+
+ );
+ },
+ ),
+);
diff --git a/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx b/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx
new file mode 100644
index 000000000..49698f57c
--- /dev/null
+++ b/packages/web/src/alpha/content-card/__stories__/ContentCard.stories.tsx
@@ -0,0 +1,477 @@
+import React from 'react';
+import { ethBackground } from '@coinbase/cds-common/internal/data/assets';
+
+import { Button, IconButton } from '../../../buttons';
+import { HStack, VStack } from '../../../layout';
+import { RemoteImage } from '../../../media';
+import { ContentCard, ContentCardBody, ContentCardFooter, ContentCardHeader } from '../index';
+
+const exampleThumbnail = (
+
+);
+
+const exampleMedia = (
+
+);
+
+const exampleMediaSquare = (
+
+);
+
+export const Default = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle="transparent"
+ thumbnail={exampleThumbnail}
+ title="Content Card"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="bgAlternate"
+ thumbnail={exampleThumbnail}
+ title="Content Card"
+ />
+
+
+
+
+
+
+ );
+};
+
+export const Interactive = (): JSX.Element => {
+ return (
+
+ alert('Card clicked!')} width={327}>
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+ );
+};
+
+export const MediaPositions = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const WithoutHeader = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const WithoutFooter = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+ );
+};
+
+export const WithoutMedia = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+ }
+ mediaPlacement="top"
+ title="No Media Card"
+ />
+
+
+
+
+
+ );
+};
+
+export const CustomContent = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle={Custom Subtitle}
+ thumbnail={exampleThumbnail}
+ title={Custom Title}
+ />
+
+ Custom description with bold text and italic text
+
+ }
+ media={exampleMedia}
+ mediaPlacement="top"
+ title={Custom Title Content}
+ />
+
+
+
+
+
+ );
+};
+
+export const MultipleCards = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+ alert('Card 3 clicked!')} width={327}>
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ subtitle="Subtitle"
+ thumbnail={exampleThumbnail}
+ title="Title"
+ />
+
+
+
+
+
+
+ );
+};
+
+export const LongContent = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+ }
+ subtitle="This is a very long subtitle text that demonstrates how the header handles longer content"
+ thumbnail={exampleThumbnail}
+ title="This is a very long title text that demonstrates how the header handles longer content"
+ />
+
+
+
+
+
+
+ );
+};
+
+export default {
+ title: 'Components/Alpha/ContentCard',
+ component: ContentCard,
+};
diff --git a/packages/web/src/alpha/content-card/index.tsx b/packages/web/src/alpha/content-card/index.tsx
new file mode 100644
index 000000000..4f5303cb0
--- /dev/null
+++ b/packages/web/src/alpha/content-card/index.tsx
@@ -0,0 +1,4 @@
+export * from './ContentCard';
+export * from './ContentCardBody';
+export * from './ContentCardFooter';
+export * from './ContentCardHeader';
diff --git a/packages/web/src/alpha/data-card/DataCard.tsx b/packages/web/src/alpha/data-card/DataCard.tsx
new file mode 100644
index 000000000..76a11fbb2
--- /dev/null
+++ b/packages/web/src/alpha/data-card/DataCard.tsx
@@ -0,0 +1,82 @@
+import { forwardRef, memo } from 'react';
+import type { ThemeVars } from '@coinbase/cds-common';
+
+import { CardRoot, type CardRootBaseProps } from '../../cards/CardRoot';
+import type { Polymorphic } from '../../core/polymorphism';
+import { cx } from '../../cx';
+
+import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout';
+
+export type DataCardBaseProps = Polymorphic.ExtendableProps<
+ Omit,
+ DataCardLayoutProps & {
+ classNames?: {
+ root?: string;
+ };
+ styles?: {
+ root?: React.CSSProperties;
+ };
+ }
+>;
+
+export type DataCardProps = Polymorphic.Props<
+ AsComponent,
+ DataCardBaseProps
+>;
+
+type DataCardComponent = ((
+ props: DataCardProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+const dataCardContainerProps = {
+ borderRadius: 500 as ThemeVars.BorderRadius,
+ flexDirection: 'row' as const,
+ background: 'bgAlternate' as ThemeVars.Color,
+ overflow: 'hidden' as const,
+};
+
+export const DataCard: DataCardComponent = memo(
+ forwardRef, DataCardBaseProps>(
+ (
+ {
+ title,
+ subtitle,
+ tag,
+ thumbnail,
+ visualization,
+ layout,
+ slotProps,
+ as,
+ children,
+ className,
+ style,
+ classNames: { root: rootClassName, ...layoutClassNames } = {},
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ ...props
+ }: DataCardProps,
+ ref?: Polymorphic.Ref,
+ ) => (
+
+
+ {children}
+
+
+ ),
+ ),
+);
diff --git a/packages/web/src/alpha/data-card/DataCardLayout.tsx b/packages/web/src/alpha/data-card/DataCardLayout.tsx
new file mode 100644
index 000000000..03dd693cb
--- /dev/null
+++ b/packages/web/src/alpha/data-card/DataCardLayout.tsx
@@ -0,0 +1,125 @@
+import React, { memo, useMemo } from 'react';
+
+import { Box, HStack, VStack } from '../../layout';
+import { Tag } from '../../tag/Tag';
+import { Text } from '../../typography';
+
+export type DataCardLayoutBaseProps = {
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
+ title: React.ReactNode;
+ /** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
+ tag?: React.ReactNode;
+ /** React node to display as a thumbnail in the header area. */
+ thumbnail?: React.ReactNode;
+ /** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. */
+ layout: 'horizontal' | 'vertical';
+ /** child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */
+ children?: React.ReactNode;
+};
+
+export type DataCardLayoutProps = DataCardLayoutBaseProps & {
+ classNames?: {
+ layoutContainer?: string;
+ headerContainer?: string;
+ headerContent?: string;
+ titleContainer?: string;
+ };
+ styles?: {
+ layoutContainer?: React.CSSProperties;
+ headerContainer?: React.CSSProperties;
+ headerContent?: React.CSSProperties;
+ titleContainer?: React.CSSProperties;
+ };
+};
+
+export const DataCardLayout = memo(
+ ({
+ title,
+ subtitle,
+ tag,
+ thumbnail,
+ layout = 'vertical',
+ classNames = {},
+ styles = {},
+ children,
+ }: DataCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(() => {
+ if (typeof subtitle === 'string') {
+ return (
+
+ {subtitle}
+
+ );
+ }
+ return subtitle;
+ }, [subtitle]);
+
+ const tagNode = useMemo(() => {
+ if (typeof tag === 'string') return {tag};
+ return tag;
+ }, [tag]);
+
+ const layoutContainerSpacingProps = useMemo(() => {
+ return {
+ flexDirection: layout === 'horizontal' ? 'row' : 'column',
+ gap: layout === 'horizontal' ? 2 : 1,
+ padding: 2,
+ } as const;
+ }, [layout]);
+
+ const headerSpacingProps = useMemo(() => {
+ return {
+ flexDirection: layout === 'horizontal' ? 'column' : 'row',
+ gap: layout === 'horizontal' ? 1.5 : 1,
+ alignItems: layout === 'horizontal' ? 'flex-start' : 'center',
+ } as const;
+ }, [layout]);
+
+ return (
+
+
+ {thumbnail}
+
+
+ {tagNode}
+ {titleNode}
+
+ {subtitleNode}
+
+
+ {children}
+
+ );
+ },
+);
diff --git a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx
new file mode 100644
index 000000000..ac01bf871
--- /dev/null
+++ b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx
@@ -0,0 +1,262 @@
+import React, { useRef } from 'react';
+import { ethBackground } from '@coinbase/cds-common/internal/data/assets';
+import {
+ ProgressBar,
+ ProgressBarWithFixedLabels,
+ ProgressCircle,
+} from '@coinbase/cds-web/visualizations';
+
+import { Box } from '../../../layout/Box';
+import { VStack } from '../../../layout/VStack';
+import { RemoteImage } from '../../../media';
+import { DataCard } from '../DataCard';
+
+const exampleThumbnail = (
+
+);
+
+// Basic Examples
+export const BasicExamples = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Features
+export const Features = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Interactive
+export const Interactive = (): JSX.Element => {
+ const ref1 = useRef(null);
+ const ref2 = useRef(null);
+ return (
+
+ alert('Progress bar card clicked!')}
+ subtitle="Clickable progress card"
+ tag="Action"
+ thumbnail={exampleThumbnail}
+ title="Actionable Progress Bar"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Style Overrides
+export const StyleOverrides = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Multiple Cards
+export const MultipleCards = (): JSX.Element => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default {
+ title: 'Components/Alpha/DataCard',
+ component: DataCard,
+};
diff --git a/packages/web/src/cards/CardRemoteImage.tsx b/packages/web/src/cards/CardRemoteImage.tsx
deleted file mode 100644
index b4cf7edb0..000000000
--- a/packages/web/src/cards/CardRemoteImage.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import React, { memo } from 'react';
-import type { CardRemoteImageProps } from '@coinbase/cds-common/types';
-
-import { RemoteImage } from '../media/RemoteImage';
-
-export type { CardRemoteImageProps };
-
-/**
- * @deprecated render a instead
- */
-export const CardRemoteImage = memo(function CardRemoteImage({
- src,
- alt = '',
- ...props
-}: CardRemoteImageProps) {
- return ;
-});
diff --git a/packages/web/src/cards/CardRoot.tsx b/packages/web/src/cards/CardRoot.tsx
new file mode 100644
index 000000000..303f33b8c
--- /dev/null
+++ b/packages/web/src/cards/CardRoot.tsx
@@ -0,0 +1,50 @@
+import React, { forwardRef, memo } from 'react';
+
+import type { Polymorphic } from '../core/polymorphism';
+import { HStack } from '../layout/HStack';
+import { Pressable, type PressableBaseProps } from '../system/Pressable';
+
+export type CardRootBaseProps = Polymorphic.ExtendableProps<
+ PressableBaseProps,
+ {
+ children?: React.ReactNode;
+ renderAsPressable?: boolean;
+ }
+>;
+
+export type CardRootProps = Polymorphic.Props<
+ AsComponent,
+ CardRootBaseProps
+>;
+
+type CardRootComponent = ((
+ props: Polymorphic.Props,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const CardRoot: CardRootComponent = memo(
+ forwardRef, CardRootBaseProps>(
+ (
+ { renderAsPressable, children, ...props }: CardRootProps,
+ ref?: Polymorphic.Ref,
+ ) => {
+ if (renderAsPressable) {
+ const { as, ...pressableRestProps } = props;
+ return (
+
+ {children}
+
+ );
+ } else {
+ const { as, ...hstackRestProps } = props;
+ return (
+
+ {children}
+
+ );
+ }
+ },
+ ),
+);
+
+CardRoot.displayName = 'CardRoot';
diff --git a/packages/web/src/cards/MediaCard/MediaCardLayout.tsx b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx
new file mode 100644
index 000000000..833aece57
--- /dev/null
+++ b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx
@@ -0,0 +1,145 @@
+import React, { memo, useMemo } from 'react';
+
+import { Box } from '../../layout';
+import { HStack } from '../../layout/HStack';
+import { VStack } from '../../layout/VStack';
+import { Text } from '../../typography/Text';
+
+export type MediaCardLayoutBaseProps = {
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component. */
+ title?: React.ReactNode;
+ /** Text or React node to display as the card subtitle. When a string is provided, it will be rendered in a CardSubtitle component. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component. */
+ description?: React.ReactNode;
+ /** React node to display as a thumbnail in the content area. */
+ thumbnail: React.ReactNode;
+ /** React node to display as the main media content. When provided, it will be rendered in a Box container taking up 50% of the card width. */
+ media?: React.ReactNode;
+ /** The position of the media within the card. */
+ mediaPlacement?: 'start' | 'end';
+};
+
+export type MediaCardLayoutProps = MediaCardLayoutBaseProps & {
+ classNames?: {
+ layoutContainer?: string;
+ contentContainer?: string;
+ textContainer?: string;
+ headerContainer?: string;
+ mediaContainer?: string;
+ };
+ styles?: {
+ layoutContainer?: React.CSSProperties;
+ contentContainer?: React.CSSProperties;
+ textContainer?: React.CSSProperties;
+ headerContainer?: React.CSSProperties;
+ mediaContainer?: React.CSSProperties;
+ };
+};
+
+export const MediaCardLayout = memo(
+ ({
+ title,
+ subtitle,
+ description,
+ thumbnail,
+ media,
+ mediaPlacement = 'end',
+ classNames = {},
+ styles = {},
+ }: MediaCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title]);
+
+ const subtitleNode = useMemo(
+ () =>
+ typeof subtitle === 'string' ? (
+
+ {subtitle}
+
+ ) : (
+ subtitle
+ ),
+ [subtitle],
+ );
+
+ const headerNode = useMemo(
+ () => (
+
+ {subtitleNode}
+ {titleNode}
+
+ ),
+ [subtitleNode, titleNode, styles?.headerContainer, classNames?.headerContainer],
+ );
+
+ const descriptionNode = useMemo(
+ () =>
+ typeof description === 'string' ? (
+
+ {description}
+
+ ) : (
+ description
+ ),
+ [description],
+ );
+
+ const contentNode = useMemo(
+ () => (
+
+ {thumbnail}
+
+ {headerNode}
+ {descriptionNode}
+
+
+ ),
+ [
+ thumbnail,
+ headerNode,
+ descriptionNode,
+ styles?.contentContainer,
+ classNames?.contentContainer,
+ classNames?.textContainer,
+ styles?.textContainer,
+ ],
+ );
+
+ const mediaNode = useMemo(() => {
+ if (media) {
+ return (
+
+ {media}
+
+ );
+ }
+ }, [media, styles?.mediaContainer, classNames?.mediaContainer]);
+
+ return (
+
+ {mediaPlacement === 'start' ? mediaNode : contentNode}
+ {mediaPlacement === 'end' ? mediaNode : contentNode}
+
+ );
+ },
+);
diff --git a/packages/web/src/cards/MediaCard/index.tsx b/packages/web/src/cards/MediaCard/index.tsx
new file mode 100644
index 000000000..13770e09d
--- /dev/null
+++ b/packages/web/src/cards/MediaCard/index.tsx
@@ -0,0 +1,82 @@
+import React, { forwardRef, memo } from 'react';
+import type { ThemeVars } from '@coinbase/cds-common';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { CardRoot, type CardRootBaseProps } from '../CardRoot';
+
+import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout';
+import { cx } from '../../cx';
+
+export type MediaCardBaseProps = Polymorphic.ExtendableProps<
+ Omit,
+ MediaCardLayoutProps & {
+ classNames?: {
+ root?: string;
+ };
+ styles?: {
+ root?: React.CSSProperties;
+ };
+ }
+>;
+
+export type MediaCardProps = Polymorphic.Props<
+ AsComponent,
+ MediaCardBaseProps
+>;
+
+const mediaCardContainerProps = {
+ borderRadius: 500 as ThemeVars.BorderRadius,
+ flexDirection: 'row' as const,
+ background: 'bgAlternate' as ThemeVars.Color,
+ overflow: 'hidden' as const,
+};
+
+type MediaCardComponent = ((
+ props: MediaCardProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const MediaCard: MediaCardComponent = memo(
+ forwardRef, MediaCardBaseProps>(
+ (
+ {
+ title,
+ subtitle,
+ description,
+ thumbnail,
+ media,
+ children,
+ mediaPlacement = 'end',
+ as,
+ classNames: { root: rootClassName, ...layoutClassNames } = {},
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ className,
+ style,
+ ...props
+ }: MediaCardProps,
+ ref?: Polymorphic.Ref,
+ ) => (
+
+
+
+ ),
+ ),
+);
+
+export { MediaCardLayout };
diff --git a/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx
new file mode 100644
index 000000000..ccdeec180
--- /dev/null
+++ b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx
@@ -0,0 +1,207 @@
+import { memo, useMemo } from 'react';
+
+import { IconButton } from '../../buttons/IconButton';
+import { Box, VStack } from '../../layout';
+import { HStack } from '../../layout/HStack';
+import { Tag } from '../../tag/Tag';
+import { Text } from '../../typography/Text';
+
+export type MessagingCardLayoutProps = {
+ /** Type of messaging card. Determines background color and text color. */
+ type: 'upsell' | 'nudge';
+ /** Text or React node to display as the card title. When a string is provided, it will be rendered in a CardTitle component with appropriate color based on type. */
+ title?: React.ReactNode;
+ /** Text or React node to display as the card subtitle. */
+ subtitle?: React.ReactNode;
+ /** Text or React node to display as the card description. When a string is provided, it will be rendered in a CardDescription component with appropriate color based on type. */
+ description?: React.ReactNode;
+ /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */
+ tag?: React.ReactNode;
+ /** React node to display as actions (typically buttons) at the bottom of the content area. */
+ actions?: React.ReactNode;
+ /** React node to display as the dismiss button. When provided, a dismiss button will be rendered in the top-right corner. */
+ dismissButton?: React.ReactNode;
+ /** Callback fired when the dismiss button is pressed. When provided, a dismiss button will be rendered in the top-right corner. */
+ onDismiss?: (event: React.MouseEvent) => void;
+ /** Accessibility label for the dismiss button.
+ * @default 'Dismiss card'
+ */
+ dismissButtonAccessibilityLabel?: string;
+ /** Placement of the media content relative to the text content. */
+ mediaPlacement: 'start' | 'end';
+ /** React node to display as the main media content. When provided, it will be rendered in a Box container. */
+ media?: React.ReactNode;
+ styles?: {
+ layoutContainer?: React.CSSProperties;
+ contentContainer?: React.CSSProperties;
+ textContainer?: React.CSSProperties;
+ mediaContainer?: React.CSSProperties;
+ dismissButtonContainer?: React.CSSProperties;
+ };
+ classNames?: {
+ layoutContainer?: string;
+ contentContainer?: string;
+ textContainer?: string;
+ mediaContainer?: string;
+ dismissButtonContainer?: string;
+ };
+};
+
+export const MessagingCardLayout = memo(
+ ({
+ type,
+ title,
+ description,
+ tag,
+ actions,
+ onDismiss,
+ dismissButtonAccessibilityLabel = 'Dismiss card',
+ mediaPlacement = 'end',
+ media,
+ styles = {},
+ classNames = {},
+ dismissButton,
+ }: MessagingCardLayoutProps) => {
+ const titleNode = useMemo(() => {
+ if (typeof title === 'string') {
+ return (
+
+ {title}
+
+ );
+ }
+ return title;
+ }, [title, type]);
+
+ const descriptionNode = useMemo(() => {
+ if (typeof description === 'string') {
+ return (
+
+ {description}
+
+ );
+ }
+ return description;
+ }, [description, type]);
+
+ const tagNode = useMemo(() => {
+ if (typeof tag === 'string') {
+ return {tag};
+ }
+ return tag;
+ }, [tag]);
+
+ const dismissButtonNode = useMemo(() => {
+ if (dismissButton) {
+ return dismissButton;
+ }
+ if (onDismiss) {
+ const handleDismiss = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onDismiss(event);
+ };
+
+ return (
+
+
+
+ );
+ }
+ return null;
+ }, [
+ classNames?.dismissButtonContainer,
+ dismissButton,
+ dismissButtonAccessibilityLabel,
+ onDismiss,
+ styles?.dismissButtonContainer,
+ ]);
+
+ const contentContainerPaddingProps = useMemo(() => {
+ if (mediaPlacement === 'start' && onDismiss) {
+ // needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button
+ // this is to avoid dismiss button from overlapping with the content area
+ return {
+ paddingY: 2,
+ paddingStart: 2,
+ paddingEnd: 6,
+ } as const;
+ }
+ return {
+ padding: 2,
+ } as const;
+ }, [mediaPlacement, onDismiss]);
+
+ const mediaContainerPaddingProps = useMemo(() => {
+ if (type === 'upsell') return;
+ if (mediaPlacement === 'start') {
+ return { paddingStart: 3, paddingEnd: 1 } as const;
+ }
+ // when media is placed at the end, we need to add additional padding to the end of the media container
+ // this is to avoid the dismiss button from overlapping with the media
+ return onDismiss
+ ? ({ paddingStart: 1, paddingEnd: 6 } as const)
+ : ({ paddingStart: 1, paddingEnd: 3 } as const);
+ }, [mediaPlacement, onDismiss, type]);
+
+ return (
+
+
+
+ {tagNode}
+ {titleNode}
+ {descriptionNode}
+
+ {actions}
+
+
+ {media}
+
+ {dismissButtonNode}
+
+ );
+ },
+);
diff --git a/packages/web/src/cards/MessagingCard/index.tsx b/packages/web/src/cards/MessagingCard/index.tsx
new file mode 100644
index 000000000..cf2e2d7ba
--- /dev/null
+++ b/packages/web/src/cards/MessagingCard/index.tsx
@@ -0,0 +1,83 @@
+import { forwardRef, memo } from 'react';
+
+import type { Polymorphic } from '../../core/polymorphism';
+import { cx } from '../../cx';
+import { CardRoot, type CardRootBaseProps } from '../CardRoot';
+
+import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout';
+
+export * from './MessagingCardLayout';
+
+export type MessagingCardBaseProps = Polymorphic.ExtendableProps<
+ Omit,
+ MessagingCardLayoutProps & {
+ classNames?: {
+ root?: string;
+ };
+ styles?: {
+ root?: React.CSSProperties;
+ };
+ }
+>;
+
+export type MessagingCardProps =
+ Polymorphic.Props;
+
+type MessagingCardComponent = ((
+ props: MessagingCardProps,
+) => Polymorphic.ReactReturn) &
+ Polymorphic.ReactNamed;
+
+export const MessagingCard: MessagingCardComponent = memo(
+ forwardRef, MessagingCardBaseProps>(
+ (
+ {
+ as,
+ type,
+ title,
+ description,
+ tag,
+ actions,
+ onDismiss,
+ dismissButtonAccessibilityLabel,
+ mediaPlacement,
+ media,
+ slotProps,
+ dismissButton,
+ styles: { root: rootStyle, ...layoutStyles } = {},
+ classNames: { root: rootClassName, ...layoutClassNames } = {},
+ className,
+ style,
+ ...props
+ }: MessagingCardProps,
+ ref?: Polymorphic.Ref,
+ ) => (
+
+
+
+ ),
+ ),
+);
diff --git a/packages/web/src/cards/__stories__/MediaCard.stories.tsx b/packages/web/src/cards/__stories__/MediaCard.stories.tsx
new file mode 100644
index 000000000..1bd3e7895
--- /dev/null
+++ b/packages/web/src/cards/__stories__/MediaCard.stories.tsx
@@ -0,0 +1,234 @@
+import React, { useRef } from 'react';
+import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets';
+
+import { Carousel } from '../../carousel/Carousel';
+import { CarouselItem } from '../../carousel/CarouselItem';
+import { VStack } from '../../layout/VStack';
+import { RemoteImage } from '../../media/RemoteImage';
+import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography';
+import { MediaCard } from '../MediaCard';
+
+const exampleProps = {
+ title: 'Title',
+ subtitle: 'Subtitle',
+ description: 'Description',
+ width: 320,
+} as const;
+
+const exampleThumbnail = (
+
+);
+
+const exampleMedia = (
+
+);
+
+// Basic Examples
+export const Basic = (): JSX.Element => {
+ return (
+
+
+
+
+ );
+};
+
+// Media Placement
+export const MediaPlacement = (): JSX.Element => {
+ return (
+
+
+
+
+ );
+};
+
+// Polymorphic and Interactive Examples
+export const PolymorphicAndInteractive = (): JSX.Element => {
+ const articleRef = useRef(null);
+ const anchorPressableRef = useRef(null);
+ const buttonPressableRef = useRef(null);
+ return (
+
+
+
+ alert('Card clicked!')}
+ subtitle="Button"
+ thumbnail={exampleThumbnail}
+ title="Interactive Card"
+ width={320}
+ />
+
+ );
+};
+
+// Text Content
+export const TextContent = (): JSX.Element => {
+ const buttonRef = useRef(null);
+ return (
+
+
+
+ Custom description with bold text and italic text
+
+ }
+ media={exampleMedia}
+ subtitle={
+
+ Custom Subtitle
+
+ }
+ thumbnail={exampleThumbnail}
+ title={Custom Title}
+ width={320}
+ />
+
+ );
+};
+
+// Styling
+export const Styling = (): JSX.Element => {
+ return (
+
+
+
+
+
+ );
+};
+
+// Multiple Cards
+export const MultipleCards = (): JSX.Element => {
+ const ref = useRef(null);
+ const ref2 = useRef(null);
+ return (
+
+
+
+
+
+
+ }
+ title="Bitcoin"
+ width={320}
+ />
+
+
+ console.log('clicked')}
+ subtitle="ETH"
+ thumbnail={exampleThumbnail}
+ title="Ethereum"
+ width={320}
+ />
+
+
+ );
+};
+
+export default {
+ title: 'Components/Cards/MediaCard',
+ component: MediaCard,
+};
diff --git a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx
new file mode 100644
index 000000000..535c4b472
--- /dev/null
+++ b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx
@@ -0,0 +1,407 @@
+import React, { useRef } from 'react';
+import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets';
+
+import { Button } from '../../buttons/Button';
+import { IconButton } from '../../buttons/IconButton';
+import { Carousel } from '../../carousel/Carousel';
+import { CarouselItem } from '../../carousel/CarouselItem';
+import { Pictogram } from '../../illustrations';
+import { Box } from '../../layout';
+import { VStack } from '../../layout/VStack';
+import { RemoteImage } from '../../media/RemoteImage';
+import { Text } from '../../typography';
+import { MessagingCard } from '../MessagingCard';
+
+const exampleProps = {
+ title: 'Title',
+ description: 'Description',
+ width: 320,
+} as const;
+
+// Basic Types
+export const BasicTypes = (): JSX.Element => {
+ return (
+
+
+ }
+ mediaPlacement="end"
+ title="Upsell Card"
+ type="upsell"
+ />
+
+ }
+ mediaPlacement="start"
+ title="Upsell Card"
+ type="upsell"
+ />
+ }
+ mediaPlacement="end"
+ title="Nudge Card"
+ type="nudge"
+ />
+ }
+ mediaPlacement="start"
+ title="Nudge Card"
+ type="nudge"
+ />
+
+ );
+};
+
+// Features
+export const Features = (): JSX.Element => {
+ return (
+
+
+ }
+ mediaPlacement="end"
+ onDismiss={() => alert('Card dismissed!')}
+ title="Dismissible Card"
+ type="upsell"
+ />
+
+ }
+ mediaPlacement="end"
+ tag="New"
+ title="Tagged Card"
+ type="upsell"
+ />
+
+ Action
+
+ }
+ description="Upsell card with action button"
+ media={
+
+ }
+ mediaPlacement="end"
+ title="Upsell with Action"
+ type="upsell"
+ />
+
+ Get Started
+
+ }
+ description="Complete upsell card with all features"
+ dismissButtonAccessibilityLabel="Dismiss"
+ media={
+
+ }
+ mediaPlacement="end"
+ onDismiss={() => alert('Dismissed')}
+ tag="New"
+ title="Complete Upsell Card"
+ type="upsell"
+ width={360}
+ />
+
+ alert('Custom dismiss pressed!')}
+ variant="secondary"
+ />
+
+ }
+ media={
+
+ }
+ mediaPlacement="end"
+ title="Custom Dismiss Button"
+ type="upsell"
+ />
+
+ );
+};
+
+// Polymorphic and Interactive Examples
+export const PolymorphicAndInteractive = (): JSX.Element => {
+ const articleRef = useRef(null);
+ const anchorRef = useRef(null);
+ const buttonRef = useRef(null);
+ return (
+
+
+ }
+ mediaPlacement="end"
+ type="upsell"
+ />
+
+ }
+ mediaPlacement="end"
+ target="_blank"
+ title="Interactive Card"
+ type="upsell"
+ width={320}
+ />
+
+ }
+ mediaPlacement="end"
+ onClick={() => alert('Card clicked!')}
+ title="Interactive Card"
+ type="upsell"
+ width={320}
+ />
+
+ );
+};
+
+// Text Content
+export const TextContent = (): JSX.Element => {
+ return (
+
+
+ }
+ mediaPlacement="end"
+ title="This is a very long title text that demonstrates text wrapping"
+ type="upsell"
+ width={320}
+ />
+
+ Custom description with bold text and italic text
+
+ }
+ media={
+
+ }
+ mediaPlacement="end"
+ tag={
+
+ Custom Tag
+
+ }
+ title={
+
+ Custom Title
+
+ }
+ type="upsell"
+ width={320}
+ />
+
+ );
+};
+
+export const MultipleCards = (): JSX.Element => {
+ const ref1 = useRef(null);
+ const ref2 = useRef(null);
+ return (
+
+
+
+ }
+ mediaPlacement="end"
+ title="Card 1"
+ type="upsell"
+ />
+
+
+ }
+ mediaPlacement="end"
+ tag="Link"
+ target="_blank"
+ title="Card 2"
+ type="nudge"
+ />
+
+
+
+ }
+ mediaPlacement="end"
+ onClick={() => console.log('clicked')}
+ tag="Action"
+ title="Card 3"
+ type="upsell"
+ />
+
+
+ );
+};
+
+export default {
+ title: 'Components/Cards/MessagingCard',
+ component: MessagingCard,
+};
diff --git a/packages/web/src/cards/index.ts b/packages/web/src/cards/index.ts
index dd0718d44..645df0ae1 100644
--- a/packages/web/src/cards/index.ts
+++ b/packages/web/src/cards/index.ts
@@ -4,6 +4,7 @@ export * from './CardFooter';
export * from './CardGroup';
export * from './CardHeader';
export * from './CardMedia';
+export * from './CardRoot';
// Card variants
export * from './AnnouncementCard';
export * from './FeatureEntryCard';
@@ -15,3 +16,5 @@ export * from './NudgeCard';
export * from './UpsellCard';
// Phoenix cards
export * from './ContentCard';
+// Media card
+export * from './MediaCard';