diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index 20e857e39e..d16bcb06b9 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -270,6 +270,11 @@ export const routes = [ key: 'Frontier', getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, }, + { + key: 'GradientBox', + getComponent: () => + require('@coinbase/cds-mobile/layout/__stories__/GradientBox.stories').default, + }, { key: 'Group', getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 45dc154916..a1bcbcd668 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -275,6 +275,11 @@ export const routes = [ key: 'Frontier', getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, }, + { + key: 'GradientBox', + getComponent: () => + require('@coinbase/cds-mobile/layout/__stories__/GradientBox.stories').default, + }, { key: 'Group', getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, diff --git a/packages/common/src/core/theme.ts b/packages/common/src/core/theme.ts index c332f87f05..f65390733f 100644 --- a/packages/common/src/core/theme.ts +++ b/packages/common/src/core/theme.ts @@ -219,6 +219,14 @@ export namespace ThemeVarsDefault { 1: void; 2: void; } + + export interface Gradient { + primary: void; + positive: void; + negative: void; + brand: void; + premium: void; + } } declare module '@coinbase/cds-common/core/theme' { @@ -251,6 +259,7 @@ declare module '@coinbase/cds-common/core/theme' { export interface Shadow {} export interface ControlSize {} export interface Elevation {} + export interface Gradient {} } } @@ -328,4 +337,8 @@ export namespace ThemeVars { export type Elevation = Prettify< keyof ThemeVarsDefault.Elevation | keyof ThemeVarsExtended.Elevation >; + + export type Gradient = Prettify< + keyof ThemeVarsDefault.Gradient | keyof ThemeVarsExtended.Gradient + >; } diff --git a/packages/common/src/tokens/button.ts b/packages/common/src/tokens/button.ts index 966fd8e8b9..0048461650 100644 --- a/packages/common/src/tokens/button.ts +++ b/packages/common/src/tokens/button.ts @@ -10,6 +10,11 @@ type ButtonVariantStyles = { type ButtonVariantConfig = Record; export const variants = { + gradient: { + color: 'fgInverse', + background: 'transparent', + borderColor: 'transparent', + }, primary: { color: 'fgInverse', background: 'bgPrimary', @@ -43,6 +48,11 @@ export const variants = { } as const satisfies ButtonVariantConfig; export const transparentVariants = { + gradient: { + color: 'fgInverse', + background: 'transparent', + borderColor: 'transparent', + }, primary: { color: 'fgPrimary', background: 'bg', diff --git a/packages/common/src/types/ButtonBaseProps.ts b/packages/common/src/types/ButtonBaseProps.ts index 32236cb19f..bb39e50260 100644 --- a/packages/common/src/types/ButtonBaseProps.ts +++ b/packages/common/src/types/ButtonBaseProps.ts @@ -1,5 +1,6 @@ export type ButtonVariant = | 'primary' + | 'gradient' | 'secondary' | 'tertiary' | 'positive' diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4b0eba778a..06b3f21be7 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -81,6 +81,10 @@ "types": "./dts/controls/index.d.ts", "default": "./esm/controls/index.js" }, + "./gradients": { + "types": "./dts/gradients/index.d.ts", + "default": "./esm/gradients/index.js" + }, "./dates": { "types": "./dts/dates/index.d.ts", "default": "./esm/dates/index.js" diff --git a/packages/mobile/src/buttons/Button.tsx b/packages/mobile/src/buttons/Button.tsx index f71fe47b47..d5175d1274 100644 --- a/packages/mobile/src/buttons/Button.tsx +++ b/packages/mobile/src/buttons/Button.tsx @@ -40,9 +40,18 @@ export const styles = StyleSheet.create({ export type ButtonBaseProps = SharedProps & Pick & - PressableBaseProps & { + Omit & { /** * Toggle design and visual variants. + * + * For gradient buttons, set `variant="gradient"` along with one of: + * - `gradient` prop with a theme preset name (e.g., "brand", "primary") + * - `gradientConfig` prop with a custom config object (e.g., `{ colors: ['#0052FF', '#7B3FE4'], angle: 90 }`) + * - `blendStyles.backgroundGradient` for state-based gradients (hover/pressed/disabled) + * - `gradientNode` prop with a custom node to render (e.g., ``) + * + * Note: gradient/gradientConfig props are ignored unless variant="gradient" is set. + * * @default primary */ variant?: ButtonVariant; @@ -84,6 +93,21 @@ export type ButtonBaseProps = SharedProps & * @default 1 */ numberOfLines?: number; + /** + * Theme gradient preset name. Only applied when `variant="gradient"`. + * @example gradient="brand" + */ + gradient?: PressableBaseProps['gradient']; + /** + * Custom gradient configuration. Only applied when `variant="gradient"`. + * @example gradientConfig={{ colors: ['#0052FF', '#7B3FE4'], angle: 90 }} + */ + gradientConfig?: PressableBaseProps['gradientConfig']; + /** + * Custom gradient node to render. Only applied when `variant="gradient"`. + * @example gradientNode={} + */ + gradientNode?: PressableBaseProps['gradientNode']; }; export type ButtonProps = ButtonBaseProps; @@ -117,10 +141,14 @@ export const Button = memo( wrapperStyles, feedback = compact ? 'light' : 'normal', borderColor, - borderWidth = 100, + // TO DO: This is a hack to fix the anti-aliasing issue with gradients. + borderWidth = variant === 'gradient' ? 0 : 100, borderRadius = compact ? 700 : 900, accessibilityLabel, accessibilityHint, + gradient, + gradientConfig, + gradientNode, ...props }: ButtonProps, ref: React.ForwardedRef, @@ -128,6 +156,7 @@ export const Button = memo( const theme = useTheme(); const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon || endIcon); + const isGradientVariant = variant === 'gradient'; const variantMap = transparent ? transparentVariants : variants; @@ -191,6 +220,9 @@ export const Button = memo( borderRadius={borderRadius} borderWidth={borderWidth} feedback={feedback} + gradient={isGradientVariant ? gradient : undefined} + gradientConfig={isGradientVariant ? gradientConfig : undefined} + gradientNode={isGradientVariant ? gradientNode : undefined} loading={loading} marginEnd={marginEnd} marginStart={marginStart} diff --git a/packages/mobile/src/buttons/IconButton.tsx b/packages/mobile/src/buttons/IconButton.tsx index 27a82bfc56..7f842196e0 100644 --- a/packages/mobile/src/buttons/IconButton.tsx +++ b/packages/mobile/src/buttons/IconButton.tsx @@ -12,8 +12,18 @@ import { Pressable, type PressableBaseProps } from '../system/Pressable'; import type { ButtonBaseProps } from './Button'; export type IconButtonBaseProps = SharedProps & - Omit & - Pick & { + Omit & + Pick< + ButtonBaseProps, + | 'disabled' + | 'transparent' + | 'compact' + | 'flush' + | 'loading' + | 'gradient' + | 'gradientConfig' + | 'gradientNode' + > & { /** Name of the icon, as defined in Figma. */ name: IconName; /** Whether the icon is active */ @@ -36,7 +46,7 @@ export const IconButton = memo(function IconButton({ background, color, borderColor, - borderWidth = 100, + borderWidth = variant === 'gradient' ? undefined : 100, borderRadius = 1000, feedback = compact ? 'light' : 'normal', flush, @@ -44,10 +54,14 @@ export const IconButton = memo(function IconButton({ style, accessibilityHint, accessibilityLabel, + gradient, + gradientConfig, + gradientNode, ...props }: IconButtonProps) { const theme = useTheme(); const iconSize = compact ? 's' : 'm'; + const isGradientVariant = variant === 'gradient'; const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -88,6 +102,9 @@ export const IconButton = memo(function IconButton({ borderRadius={borderRadius} borderWidth={borderWidth} feedback={feedback} + gradient={isGradientVariant ? gradient : undefined} + gradientConfig={isGradientVariant ? gradientConfig : undefined} + gradientNode={isGradientVariant ? gradientNode : undefined} loading={loading} marginEnd={marginEnd} marginStart={marginStart} diff --git a/packages/mobile/src/buttons/__stories__/Button.stories.tsx b/packages/mobile/src/buttons/__stories__/Button.stories.tsx index 27d061fc8e..149136ba1a 100644 --- a/packages/mobile/src/buttons/__stories__/Button.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/Button.stories.tsx @@ -1,14 +1,273 @@ import React from 'react'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; import { Icon } from '../../icons'; import { HStack } from '../../layout/HStack'; import { VStack } from '../../layout/VStack'; import { RemoteImage } from '../../media/RemoteImage'; +import { RadialGradientFill } from '../../gradients/RadialGradientFill'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients/defaultGradientTheme'; import { Text } from '../../typography/Text'; import { Button, type ButtonProps } from '../Button'; import { ButtonGroup } from '../ButtonGroup'; +/** + * Gradient button examples using preset gradients from defaultGradientTheme. + */ +const GradientButtonExamples = () => { + const theme = useTheme(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Press and hold to see the pressed gradient + + + + + + + + + + + + + + + + + + Button with base, pressed, and disabled gradient states + + + + + + + + ); +}; + const buttonStories: Omit[] = [ { variant: 'foregroundMuted' }, { variant: 'secondary' }, @@ -39,6 +298,7 @@ const buttonStories: Omit[] = [ ]; const ButtonScreen = () => { + const theme = useTheme(); return ( @@ -102,6 +362,10 @@ const ButtonScreen = () => { Hello world + + + + ); }; diff --git a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx index c2ad5397ea..d94071f8b9 100644 --- a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx @@ -3,8 +3,12 @@ import type { GestureResponderEvent } from 'react-native'; import { names } from '@coinbase/cds-icons/names'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { RadialGradientFill } from '../../gradients/RadialGradientFill'; +import { useTheme } from '../../hooks/useTheme'; import { HStack } from '../../layout'; import { Box } from '../../layout/Box'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients/defaultGradientTheme'; import { Text } from '../../typography/Text'; import { IconButton, type IconButtonProps } from '../IconButton'; @@ -65,6 +69,8 @@ const variants = [ ]; const IconButtonScreen = () => { + const theme = useTheme(); + return ( @@ -103,6 +109,60 @@ const IconButtonScreen = () => { ); })} + + + + Gradient (theme preset) + + + + Gradient (custom config) + + + + } + name="star" + variant="gradient" + /> + Gradient (custom node) + + + + Gradient (interactive state) + + {names.map((name) => { diff --git a/packages/mobile/src/core/theme.ts b/packages/mobile/src/core/theme.ts index c150632ae8..79c34e372e 100644 --- a/packages/mobile/src/core/theme.ts +++ b/packages/mobile/src/core/theme.ts @@ -1,6 +1,40 @@ import type { TextStyle, ViewStyle } from 'react-native'; import type { ColorScheme, ThemeVars } from '@coinbase/cds-common/core/theme'; +type Coordinate = { x: number; y: number }; + +/** + * Configuration for a linear gradient. + */ +export type LinearGradientConfig = { + /** + * Colors to be distributed along the gradient line. + */ + colors: string[]; + /** + * The relative positions of colors (0 to 1). If not supplied or length + * doesn't match colors, stops are auto-generated with even distribution. + */ + stops?: number[]; + /** + * Gradient angle in degrees. 0 is to top, 90 is to right, 180 is to bottom. + * @default 180 + */ + angle?: number; + /** + * Start position of the gradient as an {x, y} coordinate (0 to 1). + * Overrides the angle-based calculation when provided. + */ + start?: Coordinate; + /** + * End position of the gradient as an {x, y} coordinate (0 to 1). + * Overrides the angle-based calculation when provided. + */ + end?: Coordinate; +}; + +export type GradientConfig = LinearGradientConfig; + type Shadow = { shadowColor?: ViewStyle['shadowColor']; shadowOpacity?: ViewStyle['shadowOpacity']; @@ -45,6 +79,10 @@ export type ThemeConfig = { shadow: { [key in ThemeVars.Shadow]: Shadow }; /** The control size values. */ controlSize: { [key in ThemeVars.ControlSize]: number }; + /** Custom gradient presets for light mode. Merged with default presets. */ + lightGradient?: Partial>; + /** Custom gradient presets for dark mode. Merged with default presets. */ + darkGradient?: Partial>; }; export type Theme = ThemeConfig & { @@ -54,4 +92,6 @@ export type Theme = ThemeConfig & { spectrum: { [key in ThemeVars.SpectrumColor]: string }; /** The light or dark color palette, as appropriate based on the activeColorScheme. */ color: { [key in ThemeVars.Color]: string }; + /** The light or dark gradient presets, as appropriate based on the activeColorScheme. */ + gradient?: Partial>; }; diff --git a/packages/mobile/src/gradients/LinearGradient.tsx b/packages/mobile/src/gradients/LinearGradient.tsx index 0d349a4074..2a49ecadf3 100644 --- a/packages/mobile/src/gradients/LinearGradient.tsx +++ b/packages/mobile/src/gradients/LinearGradient.tsx @@ -3,13 +3,7 @@ import { StyleSheet, View } from 'react-native'; import { Defs, LinearGradient as Lg, Rect, Stop, Svg } from 'react-native-svg'; import type { SharedProps } from '@coinbase/cds-common'; -function getAlpha(color: string) { - const match = color.includes('rgba') && color.match(/,\s?([\d.]*)\)$/); - if (match) { - return match[1]; - } - return '1'; -} +import { getAlpha } from '../utils/getAlpha'; type Coordinate = { x: number; y: number }; @@ -65,6 +59,10 @@ type LinearGradientProps = { const defaultStops = [0, 1]; +/** + * @deprecated Use `GradientBox` from `@coinbase/cds-mobile` instead. + * GradientBox provides the same gradient functionality with better Box integration and style props support. + */ export function LinearGradient({ children, start, diff --git a/packages/mobile/src/gradients/LinearGradientFill.tsx b/packages/mobile/src/gradients/LinearGradientFill.tsx new file mode 100644 index 0000000000..3d4dfbe315 --- /dev/null +++ b/packages/mobile/src/gradients/LinearGradientFill.tsx @@ -0,0 +1,56 @@ +import { memo, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Defs, LinearGradient as Lg, Rect, Stop, Svg } from 'react-native-svg'; +import type { SharedProps } from '@coinbase/cds-common'; + +import type { LinearGradientConfig } from '../core/theme'; +import { generateEvenStops } from '../utils/generateEvenStops'; +import { getAlpha } from '../utils/getAlpha'; + +const DEFAULT_ANGLE = 180; + +export type LinearGradientFillProps = LinearGradientConfig & SharedProps; + +export const LinearGradientFill = memo( + ({ colors, stops, start, end, angle = DEFAULT_ANGLE, testID }: LinearGradientFillProps) => { + // Resolve stops: use provided stops if valid length, otherwise auto-generate + const resolvedStops = useMemo(() => { + if (stops && stops.length === colors.length) return stops; + return generateEvenStops(colors.length); + }, [colors.length, stops]); + + const coordinates = useMemo(() => { + const anglePI = (-angle * Math.PI) / 180; + return { + x1: start?.x ?? Math.round(50 + Math.sin(anglePI) * 50) / 100, + y1: start?.y ?? Math.round(50 + Math.cos(anglePI) * 50) / 100, + x2: end?.x ?? Math.round(50 + Math.sin(anglePI + Math.PI) * 50) / 100, + y2: end?.y ?? Math.round(50 + Math.cos(anglePI + Math.PI) * 50) / 100, + }; + }, [angle, start?.x, start?.y, end?.x, end?.y]); + + if (colors.length === 0) return null; + + return ( + + + + + {colors.map((color, index) => ( + + ))} + + + + + + ); + }, +); + +LinearGradientFill.displayName = 'LinearGradientFill'; diff --git a/packages/mobile/src/gradients/RadialGradientFill.tsx b/packages/mobile/src/gradients/RadialGradientFill.tsx new file mode 100644 index 0000000000..0410bb9b74 --- /dev/null +++ b/packages/mobile/src/gradients/RadialGradientFill.tsx @@ -0,0 +1,102 @@ +// TO DO: This is temporarily added, and subject to change based on design decisions. +import { memo, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Defs, RadialGradient as Rg, Rect, Stop, Svg } from 'react-native-svg'; +import type { SharedProps } from '@coinbase/cds-common'; + +import { generateEvenStops } from '../utils/generateEvenStops'; +import { getAlpha } from '../utils/getAlpha'; + +const DEFAULT_RADIAL_CENTER = 0.5; +const DEFAULT_RADIAL_RADIUS = 0.5; + +export type RadialGradientFillProps = { + /** + * Center x coordinate of the gradient (0-1). + * @default 0.5 + */ + cx?: number; + /** + * Center y coordinate of the gradient (0-1). + * @default 0.5 + */ + cy?: number; + /** + * Radius of the gradient (0-1). Used as fallback for rx and ry. + * @default 0.5 + */ + r?: number; + /** + * Horizontal radius of the gradient (0-1). For elliptical gradients. + * @default r value + */ + rx?: number; + /** + * Vertical radius of the gradient (0-1). For elliptical gradients. + * @default r value + */ + ry?: number; + /** + * Focal point x coordinate (0-1). + * @default cx value + */ + fx?: number; + /** + * Focal point y coordinate (0-1). + * @default cy value + */ + fy?: number; + /** + * The relative positions of colors. Must be the same length as colors if provided. + * Falls back to evenly distributed stops when omitted or length mismatches. + */ + stops?: number[]; + /** + * Colors to be distributed from center to edge. + */ + colors: string[]; +} & SharedProps; + +export const RadialGradientFill = memo( + ({ + colors, + stops, + cx = DEFAULT_RADIAL_CENTER, + cy = DEFAULT_RADIAL_CENTER, + r = DEFAULT_RADIAL_RADIUS, + rx, + ry, + fx, + fy, + testID, + }: RadialGradientFillProps) => { + const resolvedStops = useMemo(() => { + if (stops && stops.length === colors.length) return stops; + return generateEvenStops(colors.length); + }, [colors.length, stops]); + + if (colors.length === 0) return null; + + return ( + + + + + {colors.map((color, index) => ( + + ))} + + + + + + ); + }, +); + +RadialGradientFill.displayName = 'RadialGradientFill'; diff --git a/packages/mobile/src/gradients/__tests__/LinearGradientFill.test.tsx b/packages/mobile/src/gradients/__tests__/LinearGradientFill.test.tsx new file mode 100644 index 0000000000..e668e85f42 --- /dev/null +++ b/packages/mobile/src/gradients/__tests__/LinearGradientFill.test.tsx @@ -0,0 +1,82 @@ +import { LinearGradient as Lg, Stop } from 'react-native-svg'; +import { render, screen } from '@testing-library/react-native'; + +import { LinearGradientFill } from '../LinearGradientFill'; + +describe('LinearGradientFill', () => { + it('renders nothing when colors is empty', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders with two colors using evenly distributed stops', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops).toHaveLength(2); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBe(1); + }); + + it('renders with three colors using evenly distributed stops when stops are not provided', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops).toHaveLength(3); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBeCloseTo(0.5); + expect(stops[2].props.offset).toBe(1); + }); + + it('uses provided stops when length matches colors', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBe(0.3); + expect(stops[2].props.offset).toBe(1); + }); + + it('falls back to evenly distributed stops when stops length mismatches colors', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBeCloseTo(0.5); + expect(stops[2].props.offset).toBe(1); + }); + + it('renders a vertical gradient by default (angle=180)', () => { + render(); + const gradient = screen.UNSAFE_queryAllByType(Lg); + expect(gradient[0].props.x1).toBe(0.5); + expect(gradient[0].props.y1).toBe(0); + expect(gradient[0].props.x2).toBe(0.5); + expect(gradient[0].props.y2).toBe(1); + }); + + it('renders a horizontal gradient with angle=90', () => { + render(); + const gradient = screen.UNSAFE_queryAllByType(Lg); + expect(gradient[0].props.x1).toBe(0); + expect(gradient[0].props.y1).toBeCloseTo(0.5); + expect(gradient[0].props.x2).toBe(1); + expect(gradient[0].props.y2).toBeCloseTo(0.5); + }); + + it('uses explicit start/end coordinates over angle', () => { + render( + , + ); + const gradient = screen.UNSAFE_queryAllByType(Lg); + expect(gradient[0].props.x1).toBe(0); + expect(gradient[0].props.y1).toBe(0); + expect(gradient[0].props.x2).toBe(1); + expect(gradient[0].props.y2).toBe(1); + }); + + it('forwards testID to the wrapper view', () => { + render(); + expect(screen.getByTestId('linear-gradient')).toBeTruthy(); + }); +}); diff --git a/packages/mobile/src/gradients/__tests__/RadialGradientFill.test.tsx b/packages/mobile/src/gradients/__tests__/RadialGradientFill.test.tsx new file mode 100644 index 0000000000..7353dbae9e --- /dev/null +++ b/packages/mobile/src/gradients/__tests__/RadialGradientFill.test.tsx @@ -0,0 +1,71 @@ +import { RadialGradient as Rg, Stop } from 'react-native-svg'; +import { render, screen } from '@testing-library/react-native'; + +import { RadialGradientFill } from '../RadialGradientFill'; + +describe('RadialGradientFill', () => { + it('renders nothing when colors is empty', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders with two colors using evenly distributed stops', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops).toHaveLength(2); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBe(1); + }); + + it('renders with three colors using evenly distributed stops when stops are not provided', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops).toHaveLength(3); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBeCloseTo(0.5); + expect(stops[2].props.offset).toBe(1); + }); + + it('uses provided stops when length matches colors', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBe(0.3); + expect(stops[2].props.offset).toBe(1); + }); + + it('falls back to evenly distributed stops when stops length mismatches colors', () => { + render(); + const stops = screen.UNSAFE_queryAllByType(Stop); + expect(stops[0].props.offset).toBe(0); + expect(stops[1].props.offset).toBeCloseTo(0.5); + expect(stops[2].props.offset).toBe(1); + }); + + it('forwards cx, cy, r props to the radial gradient', () => { + render(); + const gradient = screen.UNSAFE_queryAllByType(Rg); + expect(gradient[0].props.cx).toBe(0.3); + expect(gradient[0].props.cy).toBe(0.7); + expect(gradient[0].props.r).toBe(0.4); + }); + + it('forwards rx, ry for elliptical gradients', () => { + render(); + const gradient = screen.UNSAFE_queryAllByType(Rg); + expect(gradient[0].props.rx).toBe(0.6); + expect(gradient[0].props.ry).toBe(0.3); + }); + + it('forwards fx, fy focal point props', () => { + render(); + const gradient = screen.UNSAFE_queryAllByType(Rg); + expect(gradient[0].props.fx).toBe(0.2); + expect(gradient[0].props.fy).toBe(0.8); + }); + + it('forwards testID to the wrapper view', () => { + render(); + expect(screen.getByTestId('radial-gradient')).toBeTruthy(); + }); +}); diff --git a/packages/mobile/src/gradients/index.ts b/packages/mobile/src/gradients/index.ts new file mode 100644 index 0000000000..cd30af8dab --- /dev/null +++ b/packages/mobile/src/gradients/index.ts @@ -0,0 +1,3 @@ +export { LinearGradient } from './LinearGradient'; +export { LinearGradientFill } from './LinearGradientFill'; +export { RadialGradientFill, type RadialGradientFillProps } from './RadialGradientFill'; diff --git a/packages/mobile/src/layout/GradientBox.tsx b/packages/mobile/src/layout/GradientBox.tsx new file mode 100644 index 0000000000..388e82ac21 --- /dev/null +++ b/packages/mobile/src/layout/GradientBox.tsx @@ -0,0 +1,72 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import type { View } from 'react-native'; +import type { ThemeVars } from '@coinbase/cds-common/core/theme'; + +import type { GradientConfig } from '../core/theme'; +import { LinearGradientFill } from '../gradients/LinearGradientFill'; +import { useTheme } from '../hooks/useTheme'; + +import { Box, type BoxProps } from './Box'; + +export type GradientBoxBaseProps = { + /** + * Theme gradient preset name. Resolved from theme configuration. + * Ignored when `gradientConfig` is provided. + * @example "brand", "primary", "positive" + */ + gradient?: ThemeVars.Gradient; + /** + * Custom linear gradient configuration. Rendered as SVG LinearGradient. + * Use this for dynamic or non-theme gradients. + * Takes precedence over `gradient` when both are provided. + * @example { colors: ['#0052FF', '#7B3FE4'], angle: 90 } + */ + gradientConfig?: GradientConfig; + /** + * @default false + * Gradient will overlay the children content when true. + */ + elevated?: boolean; + /** + * Override the default linear gradient with a custom gradient node. + * Use for radial, conic, or other gradient types. + * @example + */ + gradientNode?: React.ReactNode; +}; + +export type GradientBoxProps = GradientBoxBaseProps & BoxProps; + +export const GradientBox = memo( + forwardRef( + ( + { elevated, children, gradient, gradientConfig, overflow = 'hidden', gradientNode, ...props }, + ref, + ) => { + const theme = useTheme(); + + const resolvedConfig = useMemo(() => { + if (gradientConfig) return gradientConfig; + if (gradient && theme.gradient?.[gradient]) return theme.gradient[gradient]; + return undefined; + }, [gradient, gradientConfig, theme.gradient]); + + const defaultGradient = useMemo(() => { + if (!resolvedConfig?.colors) return null; + return ; + }, [resolvedConfig]); + + const renderedGradient = gradientNode ?? defaultGradient; + + const items = elevated ? [children, renderedGradient] : [renderedGradient, children]; + + return ( + + {items} + + ); + }, + ), +); + +GradientBox.displayName = 'GradientBox'; diff --git a/packages/mobile/src/layout/__stories__/GradientBox.stories.tsx b/packages/mobile/src/layout/__stories__/GradientBox.stories.tsx new file mode 100644 index 0000000000..64af54dad3 --- /dev/null +++ b/packages/mobile/src/layout/__stories__/GradientBox.stories.tsx @@ -0,0 +1,209 @@ +import React from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients/defaultGradientTheme'; +import { Text } from '../../typography/Text'; +import { GradientBox } from '../GradientBox'; +import { RadialGradientFill } from '../../gradients/RadialGradientFill'; + +const BasicGradient = () => ( + + + + brand gradient + + + + + + Primary gradient + + + + + + Positive gradient + + + + + + Negative gradient + + + + + + Premium gradient + + + +); + +const GradientBoxScreen = () => { + const theme = useTheme(); + return ( + + + + + + + Horizontal (90° angle) + + + + + + Vertical (180° angle) + + + + + + Diagonal (135° angle) + + + + + + Custom 45° angle + + + + + + + + Rainbow gradient + + + + + + + + Elevated gradient + + + + + + + + With border and elevation + + + + + + + } + height={120} + padding={2} + > + + Default radial gradient + + + + + } + height={120} + padding={2} + > + + Top-left radial gradient + + + + + } + height={120} + padding={2} + > + + Multi-color radial gradient + + + + + + ); +}; + +export default GradientBoxScreen; diff --git a/packages/mobile/src/layout/__tests__/GradientBox.test.tsx b/packages/mobile/src/layout/__tests__/GradientBox.test.tsx new file mode 100644 index 0000000000..d7b5a9c436 --- /dev/null +++ b/packages/mobile/src/layout/__tests__/GradientBox.test.tsx @@ -0,0 +1,88 @@ +import { Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; +import { Svg } from 'react-native-svg'; + +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; +import type { GradientBoxProps } from '../GradientBox'; +import { GradientBox as GradientBoxComponent } from '../GradientBox'; + +const GradientBox = (props: GradientBoxProps) => ( + + + +); + +describe('GradientBox', () => { + it('renders SVG gradient with colors', async () => { + render( + + Child + , + ); + + await screen.findByTestId('parent'); + + expect(screen.UNSAFE_queryAllByType(Svg)).toHaveLength(1); + }); + + it('renders SVG gradient with custom angle', async () => { + render( + + Child + , + ); + + await screen.findByTestId('parent'); + + expect(screen.UNSAFE_queryAllByType(Svg)).toHaveLength(1); + }); + + it('renders children correctly', async () => { + render( + + Child Content + , + ); + + await screen.findByTestId('parent'); + expect(screen.getByTestId('child')).toBeTruthy(); + expect(screen.getByText('Child Content')).toBeTruthy(); + }); + + it('applies Box style props', async () => { + render( + + Child + , + ); + + await screen.findByTestId('parent'); + + expect(screen.getByTestId('parent')).toHaveStyle({ + borderRadius: 8, + borderWidth: 1, + padding: 16, + }); + }); + + it('sets overflow hidden for gradient clipping', async () => { + render( + + Child + , + ); + + await screen.findByTestId('parent'); + + expect(screen.getByTestId('parent')).toHaveStyle({ + overflow: 'hidden', + }); + }); +}); diff --git a/packages/mobile/src/layout/index.ts b/packages/mobile/src/layout/index.ts index 062daeb861..488ef521ad 100644 --- a/packages/mobile/src/layout/index.ts +++ b/packages/mobile/src/layout/index.ts @@ -1,5 +1,6 @@ export * from './Box'; export * from './Divider'; +export * from './GradientBox'; export * from './Fallback'; export * from './Group'; export * from './HStack'; diff --git a/packages/mobile/src/system/Interactable.tsx b/packages/mobile/src/system/Interactable.tsx index 8151603090..03cea9f7c9 100644 --- a/packages/mobile/src/system/Interactable.tsx +++ b/packages/mobile/src/system/Interactable.tsx @@ -4,6 +4,7 @@ import type { ElevationLevels, ThemeVars } from '@coinbase/cds-common'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; +import { GradientBox, type GradientBoxBaseProps } from '../layout/GradientBox'; import { getInteractableStyles } from '../styles/getInteractableStyles'; /** @@ -18,7 +19,8 @@ import { getInteractableStyles } from '../styles/getInteractableStyles'; * blendStyles={{ * background: '#ffffff', * pressedBackground: '#e0e0e0', - * borderColor: '#cccccc' + * borderColor: '#cccccc', + * pressedBackgroundGradient: { colors: ['#003cb8', '#5b1fb4'], angle: 90 }, * }} * /> * ``` @@ -30,53 +32,60 @@ export type InteractableBlendStyles = { borderColor?: string; pressedBorderColor?: string; disabledBorderColor?: string; + /** Gradient to apply as the default background. Overrides `gradientConfig` when set. */ + backgroundGradient?: GradientBoxBaseProps['gradientConfig']; + /** Gradient to apply when the element is pressed. Overrides the base gradient for the pressed state. */ + pressedBackgroundGradient?: GradientBoxBaseProps['gradientConfig']; + /** Gradient to apply when the element is disabled. Overrides the base gradient for the disabled state. */ + disabledBackgroundGradient?: GradientBoxBaseProps['gradientConfig']; }; -export type InteractableBaseProps = Omit & { - /** Apply animated styles to the outer container. */ - style?: Animated.WithAnimatedValue>[]; - /** Background color of the overlay (element being interacted with). */ - background?: ThemeVars.Color; - /** Set element to block and expand to 100% width. */ - block?: boolean; - /** Is the element currently disabled. */ - disabled?: boolean; - /** Is the element elevated. */ - elevation?: ElevationLevels; - /** - * Is the element currenty loading. - * When set to true, will disable element from press and keyboard events - */ - loading?: boolean; - /** Is the element being pressed. Primarily a mobile feature, but can be used on the web. */ - pressed?: boolean; - /** - * Mark the background and border as transparent until the element is interacted with (hovered, pressed, etc). - * Must be used in conjunction with the "pressed" prop - * */ - transparentWhileInactive?: boolean; - /** - * Mark the background and border as transparent even while element is interacted with (elevation underlay issue). - * Must be used in conjunction with the "pressed" prop - * */ - transparentWhilePressed?: boolean; - blendStyles?: InteractableBlendStyles; - /** Apply animated styles to the inner container. */ - contentStyle?: StyleProp; - /** Apply styles to the outer container. */ - wrapperStyles?: { - base?: StyleProp; - pressed?: StyleProp; - disabled?: StyleProp; +export type InteractableBaseProps = Omit & + Pick & { + /** Apply animated styles to the outer container. */ + style?: Animated.WithAnimatedValue>[]; + /** Background color of the overlay (element being interacted with). */ + background?: ThemeVars.Color; + /** Set element to block and expand to 100% width. */ + block?: boolean; + /** Is the element currently disabled. */ + disabled?: boolean; + /** Is the element elevated. */ + elevation?: ElevationLevels; + /** + * Is the element currenty loading. + * When set to true, will disable element from press and keyboard events + */ + loading?: boolean; + /** Is the element being pressed. Primarily a mobile feature, but can be used on the web. */ + pressed?: boolean; + /** + * Mark the background and border as transparent until the element is interacted with (hovered, pressed, etc). + * Must be used in conjunction with the "pressed" prop + * */ + transparentWhileInactive?: boolean; + /** + * Mark the background and border as transparent even while element is interacted with (elevation underlay issue). + * Must be used in conjunction with the "pressed" prop + * */ + transparentWhilePressed?: boolean; + blendStyles?: InteractableBlendStyles; + /** Apply animated styles to the inner container. */ + contentStyle?: StyleProp; + /** Apply styles to the outer container. */ + wrapperStyles?: { + base?: StyleProp; + pressed?: StyleProp; + disabled?: StyleProp; + }; }; -}; export type InteractableProps = InteractableBaseProps & Omit; export const Interactable = memo(function Interactable({ background = 'transparent', borderColor = background, - borderWidth = 100, + borderWidth, block, children, disabled, @@ -87,6 +96,9 @@ export const Interactable = memo(function Interactable({ blendStyles, transparentWhileInactive, transparentWhilePressed, + gradient, + gradientConfig, + gradientNode, ...props }: InteractableProps) { const theme = useTheme(); @@ -118,6 +130,41 @@ export const Interactable = memo(function Interactable({ }); }, [theme, background, isTransparent, isPressedAndTransparent, blendStyles, borderColor]); + // Resolve active gradient config based on interaction state + // Priority: disabled > pressed > blendStyles.backgroundGradient > gradientConfig > gradient (theme preset) + const activeGradientConfig = useMemo(() => { + const baseGradientConfig = + blendStyles?.backgroundGradient ?? + gradientConfig ?? + (gradient ? theme.gradient?.[gradient] : undefined); + + // Disabled state takes highest priority + if (disabled && blendStyles?.disabledBackgroundGradient) { + return blendStyles.disabledBackgroundGradient; + } + + // Pressed state takes priority over base + if (pressed && blendStyles?.pressedBackgroundGradient) { + return blendStyles.pressedBackgroundGradient; + } + + // Fall back to base gradient config + return baseGradientConfig; + }, [ + blendStyles?.backgroundGradient, + blendStyles?.disabledBackgroundGradient, + blendStyles?.pressedBackgroundGradient, + gradient, + gradientConfig, + theme.gradient, + disabled, + pressed, + ]); + + const hasGradient = !!(activeGradientConfig || gradientNode); + + const resolvedBorderWidth = borderWidth ?? (hasGradient ? undefined : 100); + const mergedWrapperStyles = useMemo( () => [ block && { flexGrow: 1 }, @@ -138,15 +185,21 @@ export const Interactable = memo(function Interactable({ [contentStyle, contentStyles.disabled, contentStyles.pressed, disabled, pressed], ); + const content = {children}; + + const Wrapper = hasGradient ? GradientBox : Box; + return ( - - {children} - + {content} + ); }); diff --git a/packages/mobile/src/system/Pressable.tsx b/packages/mobile/src/system/Pressable.tsx index 743170e151..a4feee327b 100644 --- a/packages/mobile/src/system/Pressable.tsx +++ b/packages/mobile/src/system/Pressable.tsx @@ -145,6 +145,9 @@ export const Pressable = memo( flexShrink, flexGrow, opacity, + gradient, + gradientConfig, + gradientNode, // Pressable disableDebounce, feedback = 'none', @@ -280,6 +283,9 @@ export const Pressable = memo( fontSize={fontSize} fontWeight={fontWeight} gap={gap} + gradient={gradient} + gradientConfig={gradientConfig} + gradientNode={gradientNode} height={height} justifyContent={justifyContent} left={left} diff --git a/packages/mobile/src/system/ThemeProvider.tsx b/packages/mobile/src/system/ThemeProvider.tsx index 8c8cdddca4..ebce78a6ee 100644 --- a/packages/mobile/src/system/ThemeProvider.tsx +++ b/packages/mobile/src/system/ThemeProvider.tsx @@ -21,6 +21,7 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi const themeApi = useMemo(() => { const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; + const activeGradientKey = activeColorScheme === 'dark' ? 'darkGradient' : 'lightGradient'; const inverseSpectrumKey = activeColorScheme === 'dark' ? 'lightSpectrum' : 'darkSpectrum'; const inverseColorKey = activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; @@ -49,6 +50,7 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi activeColorScheme: activeColorScheme, spectrum: theme[activeSpectrumKey], color: theme[activeColorKey], + gradient: theme[activeGradientKey], }; }, [theme, activeColorScheme]); diff --git a/packages/mobile/src/themes/gradients/coinbaseGradientTheme.ts b/packages/mobile/src/themes/gradients/coinbaseGradientTheme.ts new file mode 100644 index 0000000000..4630f8893d --- /dev/null +++ b/packages/mobile/src/themes/gradients/coinbaseGradientTheme.ts @@ -0,0 +1,82 @@ +import type { ThemeConfig } from '../../core/theme'; +import { coinbaseTheme } from '../coinbaseTheme'; + +export const coinbaseGradientThemeId = 'cds-coinbase-gradient'; + +const { lightColor, darkColor } = coinbaseTheme; + +/** + * Coinbase gradient presets for light mode. + * Uses color tokens from the Coinbase theme. + */ +export const coinbaseLightGradient = { + primary: { + colors: [lightColor.bgPrimary, lightColor.bgPrimary], + angle: 180, + }, + positive: { + colors: [lightColor.bgPositive, lightColor.bgPositive], + angle: 180, + }, + negative: { + colors: [lightColor.bgNegative, lightColor.bgNegative], + angle: 180, + }, + brand: { + colors: [lightColor.accentBoldBlue, lightColor.accentBoldPurple], + angle: 90, + }, + premium: { + colors: [lightColor.accentBoldPurple, lightColor.accentBoldBlue, lightColor.accentBoldGreen], + stops: [0, 0.5, 1], + angle: 135, + }, +} as const satisfies ThemeConfig['lightGradient']; + +/** + * Coinbase gradient presets for dark mode. + * Uses color tokens from the Coinbase theme. + */ +export const coinbaseDarkGradient = { + primary: { + colors: [darkColor.bgPrimary, darkColor.bgPrimary], + angle: 180, + }, + positive: { + colors: [darkColor.bgPositive, darkColor.bgPositive], + angle: 180, + }, + negative: { + colors: [darkColor.bgNegative, darkColor.bgNegative], + angle: 180, + }, + brand: { + colors: [darkColor.accentBoldBlue, darkColor.accentBoldPurple], + angle: 90, + }, + premium: { + colors: [darkColor.accentBoldPurple, darkColor.accentBoldBlue, darkColor.accentBoldGreen], + stops: [0, 0.5, 1], + angle: 135, + }, +} as const satisfies ThemeConfig['darkGradient']; + +/** + * Coinbase theme with gradient presets enabled. + * A complete theme configuration ready to use with ThemeProvider. + * + * @example + * ```tsx + * import { coinbaseGradientTheme } from '@coinbase/cds-mobile/themes/gradients'; + * + * + * + * + * ``` + */ +export const coinbaseGradientTheme = { + ...coinbaseTheme, + id: coinbaseGradientThemeId, + lightGradient: coinbaseLightGradient, + darkGradient: coinbaseDarkGradient, +} as const satisfies ThemeConfig; diff --git a/packages/mobile/src/themes/gradients/defaultGradientTheme.ts b/packages/mobile/src/themes/gradients/defaultGradientTheme.ts new file mode 100644 index 0000000000..bde6cb741b --- /dev/null +++ b/packages/mobile/src/themes/gradients/defaultGradientTheme.ts @@ -0,0 +1,82 @@ +import type { ThemeConfig } from '../../core/theme'; +import { defaultTheme } from '../defaultTheme'; + +export const defaultGradientThemeId = 'cds-default-gradient'; + +const { lightColor, darkColor } = defaultTheme; + +/** + * Default gradient presets for light mode. + * Uses color tokens from the default theme. + */ +export const defaultLightGradient = { + primary: { + colors: [lightColor.bgPrimary, lightColor.bgPrimaryWash], + angle: 180, + }, + positive: { + colors: [lightColor.bgPositive, lightColor.bgPositiveWash], + angle: 180, + }, + negative: { + colors: [lightColor.bgNegative, lightColor.bgNegativeWash], + angle: 180, + }, + brand: { + colors: [lightColor.accentBoldBlue, lightColor.accentBoldPurple], + angle: 90, + }, + premium: { + colors: [lightColor.accentBoldPurple, lightColor.accentBoldBlue, lightColor.accentBoldGreen], + stops: [0, 0.5, 1], + angle: 135, + }, +} as const satisfies ThemeConfig['lightGradient']; + +/** + * Default gradient presets for dark mode. + * Uses color tokens from the default theme. + */ +export const defaultDarkGradient = { + primary: { + colors: [darkColor.bgPrimary, darkColor.bgPrimary], + angle: 180, + }, + positive: { + colors: [darkColor.bgPositive, darkColor.transparent], + angle: 180, + }, + negative: { + colors: [darkColor.bgNegative, darkColor.bgNegative], + angle: 180, + }, + brand: { + colors: [darkColor.accentBoldBlue, darkColor.accentBoldPurple], + angle: 90, + }, + premium: { + colors: [darkColor.accentBoldPurple, darkColor.accentBoldBlue, darkColor.accentBoldGreen], + stops: [0, 0.5, 1], + angle: 135, + }, +} as const satisfies ThemeConfig['darkGradient']; + +/** + * Default theme with gradient presets enabled. + * A complete theme configuration ready to use with ThemeProvider. + * + * @example + * ```tsx + * import { defaultGradientTheme } from '@coinbase/cds-mobile/themes/gradients'; + * + * + * + * + * ``` + */ +export const defaultGradientTheme = { + ...defaultTheme, + id: defaultGradientThemeId, + lightGradient: defaultLightGradient, + darkGradient: defaultDarkGradient, +} as const satisfies ThemeConfig; diff --git a/packages/mobile/src/themes/gradients/index.ts b/packages/mobile/src/themes/gradients/index.ts new file mode 100644 index 0000000000..ef76190af1 --- /dev/null +++ b/packages/mobile/src/themes/gradients/index.ts @@ -0,0 +1,10 @@ +export { + coinbaseDarkGradient, + coinbaseGradientTheme, + coinbaseLightGradient, +} from './coinbaseGradientTheme'; +export { + defaultDarkGradient, + defaultGradientTheme, + defaultLightGradient, +} from './defaultGradientTheme'; diff --git a/packages/mobile/src/utils/generateEvenStops.ts b/packages/mobile/src/utils/generateEvenStops.ts new file mode 100644 index 0000000000..43e0539623 --- /dev/null +++ b/packages/mobile/src/utils/generateEvenStops.ts @@ -0,0 +1,8 @@ +/** + * Generates evenly distributed stop positions for the given number of colors. + * @example generateEvenStops(3) => [0, 0.5, 1] + */ +export const generateEvenStops = (colorCount: number): number[] => { + if (colorCount <= 1) return [0]; + return Array.from({ length: colorCount }, (_, i) => i / (colorCount - 1)); +}; diff --git a/packages/mobile/src/utils/getAlpha.ts b/packages/mobile/src/utils/getAlpha.ts new file mode 100644 index 0000000000..5a65b579cf --- /dev/null +++ b/packages/mobile/src/utils/getAlpha.ts @@ -0,0 +1,17 @@ +/** + * Extracts the alpha (opacity) value from an RGBA color string. + * + * @param color - A color string, e.g. "rgba(255, 0, 0, 0.5)" or "#FF0000" + * @returns The alpha value as a string (0-1), defaults to "1" if not an RGBA color + * + * @example + * getAlpha("rgba(255, 0, 0, 0.5)") // returns "0.5" + * getAlpha("#FF0000") // returns "1" + */ +export function getAlpha(color: string) { + const match = color.includes('rgba') && color.match(/,\s?([\d.]*)\)$/); + if (match) { + return match[1]; + } + return '1'; +} diff --git a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts b/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts index 7202df2dbe..581ea65369 100644 --- a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts +++ b/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts @@ -3193,4 +3193,4 @@ export type SvgMapEntry = { content: string }; export type SvgMap = Record; export type SvgKey = keyof typeof svgMap; -export default svgMap; +export default svgMap; \ No newline at end of file diff --git a/packages/web/src/buttons/Button.tsx b/packages/web/src/buttons/Button.tsx index 41d570c02d..e322df178a 100644 --- a/packages/web/src/buttons/Button.tsx +++ b/packages/web/src/buttons/Button.tsx @@ -118,11 +118,20 @@ export const buttonDefaultElement = 'button'; export type ButtonDefaultElement = typeof buttonDefaultElement; export type ButtonBaseProps = Polymorphic.ExtendableProps< - PressableBaseProps, + Omit, SharedProps & Pick & { /** * Toggle design and visual variants. + * + * For gradient buttons, set `variant="gradient"` along with one of: + * - `gradient` prop with a theme preset name (e.g., "brand", "primary") + * - `gradientConfig` prop with a custom config object (e.g., `{ colors: ['#0052FF', '#7B3FE4'], angle: 90 }`) + * - `blendStyles.backgroundGradient` for state-based gradients (hover/pressed/disabled) + * - `style={{ backgroundImage: '...' }}` for radial, conic, or other advanced gradients + * + * Note: gradient/gradientConfig props are ignored unless variant="gradient" is set. + * * @default primary */ variant?: ButtonVariant; @@ -164,6 +173,16 @@ export type ButtonBaseProps = Polymorphic.ExtendableProps< * @default 1 */ numberOfLines?: number; + /** + * Theme gradient preset name. Only applied when `variant="gradient"`. + * @example gradient="brand" + */ + gradient?: PressableBaseProps['gradient']; + /** + * Custom gradient configuration. Only applied when `variant="gradient"`. + * @example gradientConfig={{ colors: ['#0052FF', '#7B3FE4'], angle: 90 }} + */ + gradientConfig?: PressableBaseProps['gradientConfig']; } >; @@ -208,13 +227,15 @@ export const Button: ButtonComponent = memo( // TO DO: get rid of this height and interactableHeight (mobile and web both) height = compact ? 40 : 56, borderColor, - borderWidth = 100, + borderWidth = variant === 'gradient' ? 0 : 100, borderRadius = compact ? 700 : 900, accessibilityLabel, padding, paddingX = padding ?? (compact ? 2 : 4), margin = 0, minWidth = compact ? 'auto' : DEFAULT_MIN_WIDTH, + gradient, + gradientConfig, ...props }: ButtonProps, ref?: Polymorphic.Ref, @@ -222,6 +243,7 @@ export const Button: ButtonComponent = memo( const Component = (as ?? buttonDefaultElement) satisfies React.ElementType; const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon ?? endIcon); + const isGradientVariant = variant === 'gradient'; const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -256,6 +278,8 @@ export const Button: ButtonComponent = memo( data-flush={flush} data-transparent={transparent} data-variant={variant} + gradient={isGradientVariant ? gradient : undefined} + gradientConfig={isGradientVariant ? gradientConfig : undefined} height={height} loading={loading} margin={margin} diff --git a/packages/web/src/buttons/IconButton.tsx b/packages/web/src/buttons/IconButton.tsx index d3fe02c6f8..3ad3a7eab9 100644 --- a/packages/web/src/buttons/IconButton.tsx +++ b/packages/web/src/buttons/IconButton.tsx @@ -26,8 +26,11 @@ export const iconButtonDefaultElement = 'button'; export type IconButtonDefaultElement = typeof iconButtonDefaultElement; export type IconButtonBaseProps = Polymorphic.ExtendableProps< - Omit, - Pick & { + Omit, + Pick< + ButtonBaseProps, + 'disabled' | 'transparent' | 'compact' | 'flush' | 'gradient' | 'gradientConfig' + > & { /** Name of the icon, as defined in Figma. */ name: IconName; /** Whether the icon is active */ @@ -76,7 +79,7 @@ export const IconButton: IconButtonComponent = memo( color, borderColor, borderRadius = 1000, - borderWidth = 100, + borderWidth = variant === 'gradient' ? undefined : 100, alignItems = 'center', justifyContent = 'center', // TO DO: fix this when removing interactableHeight @@ -89,6 +92,8 @@ export const IconButton: IconButtonComponent = memo( loading, accessibilityLabel, accessibilityHint, + gradient, + gradientConfig, ...props }: IconButtonProps, ref?: Polymorphic.Ref, @@ -107,6 +112,7 @@ export const IconButton: IconButtonComponent = memo( [iconSizeValue], ); + const isGradientVariant = variant === 'gradient'; const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -137,6 +143,8 @@ export const IconButton: IconButtonComponent = memo( data-flush={flush} data-transparent={transparent} data-variant={variant} + gradient={isGradientVariant ? gradient : undefined} + gradientConfig={isGradientVariant ? gradientConfig : undefined} height={height} justifyContent={justifyContent} loading={loading} diff --git a/packages/web/src/buttons/__stories__/Button.stories.tsx b/packages/web/src/buttons/__stories__/Button.stories.tsx index 0aafa6118d..5d0983bad4 100644 --- a/packages/web/src/buttons/__stories__/Button.stories.tsx +++ b/packages/web/src/buttons/__stories__/Button.stories.tsx @@ -1,10 +1,37 @@ import React from 'react'; +import { css } from '@linaria/core'; +import { useTheme } from '../../hooks/useTheme'; import { Icon } from '../../icons/Icon'; import { VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients/defaultGradientTheme'; +import { Text } from '../../typography/Text'; import { Button, type ButtonBaseProps } from '../Button'; import { ButtonGroup } from '../ButtonGroup'; +const radialGradientButtonCss = css` + background-image: radial-gradient(circle at center, #0052ff, #7b3fe4); +`; + +const animatedGradientButtonCss = css` + background: linear-gradient(270deg, #0052ff, #7b3fe4, #00cc66, #0052ff); + background-size: 400% 400%; + animation: gradientShift 3s ease infinite; + + @keyframes gradientShift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } +`; + export default { component: Button, title: 'Components/Buttons/Button', @@ -95,3 +122,199 @@ export const FlushProps = () => ( ); + +export const GradientButtons = () => { + const { activeColorScheme } = useTheme(); + + return ( + + + + ); +}; + +function GradientButtonsContent() { + const theme = useTheme(); + + return ( + + {/* Theme Gradient Presets */} + + + Theme Gradient Presets (gradient) + + + {(['primary', 'brand', 'positive', 'negative', 'premium'] as const).map((preset) => ( + + ))} + + + + {/* Custom Linear Gradients */} + + + Custom Linear Gradients (gradientConfig) + + + + + + + {/* Different Gradient Styles (radial/conic via style prop) */} + + + {/* Gradient with Interactive States */} + + + Gradient with Hover/Active States + + + + + + ); +} + +function DifferentGradientStyles() { + const theme = useTheme(); + + const radialGradient = `radial-gradient(circle, ${theme.color.accentBoldBlue}, ${theme.color.accentBoldPurple})`; + const conicGradient = [ + 'conic-gradient(from 0deg', + theme.color.bgNegative, + theme.color.bgWarning, + theme.color.bgPositive, + theme.color.accentBoldBlue, + `${theme.color.bgNegative})`, + ].join(', '); + const blue = theme.color.accentBoldBlue; + const purple = theme.color.accentBoldPurple; + const repeatingGradient = `repeating-linear-gradient(45deg, ${blue}, ${blue} 10px, ${purple} 10px, ${purple} 20px)`; + + return ( + + + Different Gradient Styles + + + + + + + + Via className + + + + + ); +} diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index 72ee44bd65..38876dedb3 100644 --- a/packages/web/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconButton.stories.tsx @@ -1,7 +1,10 @@ import React from 'react'; import { names } from '@coinbase/cds-icons/names'; +import { useTheme } from '../../hooks/useTheme'; import { HStack, VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients/defaultGradientTheme'; import { Text } from '../../typography/Text'; import { IconButton, type IconButtonBaseProps } from '../IconButton'; @@ -129,3 +132,111 @@ export default { title: 'Components/Buttons/IconButton', component: IconButton, }; + +export const GradientVariants = () => { + const theme = useTheme(); + return ( + + + Theme gradient preset (gradient prop) + + + + + + + + + + + + Custom gradient (gradientConfig prop) + + + + + + + + + Gradient interaction states (blendStyles — hover/press/disabled) + + Hover and press to see gradient transitions + + + + + + + + ); +}; diff --git a/packages/web/src/core/theme.ts b/packages/web/src/core/theme.ts index 86c31630bc..d40b760f81 100644 --- a/packages/web/src/core/theme.ts +++ b/packages/web/src/core/theme.ts @@ -38,6 +38,10 @@ export type ThemeConfig = { shadow: { [key in ThemeVars.Shadow]: Property.BoxShadow }; /** The control size values. */ controlSize: { [key in ThemeVars.ControlSize]: number }; + /** Custom gradient presets for light mode. Merged with default presets. */ + lightGradient?: Partial>; + /** Custom gradient presets for dark mode. Merged with default presets. */ + darkGradient?: Partial>; }; export type Theme = ThemeConfig & { @@ -47,6 +51,8 @@ export type Theme = ThemeConfig & { spectrum: { [key in ThemeVars.SpectrumColor]: string }; /** The light or dark color palette, as appropriate based on the activeColorScheme. */ color: { [key in ThemeVars.Color]: Property.Color }; + /** The light or dark gradient presets, as appropriate based on the activeColorScheme. */ + gradient?: Partial>; }; /** Maps our StyleVars to their CSS variable prefixes. For example, the names of CSS vars generated from `iconSize` vars will be prefixed with `--iconSize-`. */ @@ -70,6 +76,9 @@ export const styleVarPrefixes = { textTransform: 'textTransform', shadow: 'shadow', controlSize: 'controlSize', + lightGradient: 'lightGradient', + darkGradient: 'darkGradient', + gradient: 'gradient', } as const satisfies Record, string>; /** Used to generate intellisense via ThemeCSSVars below. */ @@ -116,6 +125,9 @@ type ThemeObjectCssVars = { controlSize: { [key in ThemeVars.ControlSize as `--${typeof styleVarPrefixes.controlSize}-${key}`]: Property.Width; }; + gradient: { + [key in ThemeVars.Gradient as `--${typeof styleVarPrefixes.gradient}-${key}`]: Property.BackgroundImage; + }; }; type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends ( diff --git a/packages/web/src/layout/Box.tsx b/packages/web/src/layout/Box.tsx index 784c1b28fe..e81b2d7e48 100644 --- a/packages/web/src/layout/Box.tsx +++ b/packages/web/src/layout/Box.tsx @@ -13,7 +13,7 @@ export const boxDefaultElement = 'div'; export type BoxDefaultElement = typeof boxDefaultElement; -export type BoxBaseProps = StyleProps & +export type BoxBaseProps = Omit & SharedProps & Pick< SharedAccessibilityProps, diff --git a/packages/web/src/layout/GradientBox.tsx b/packages/web/src/layout/GradientBox.tsx new file mode 100644 index 0000000000..dcdbc92214 --- /dev/null +++ b/packages/web/src/layout/GradientBox.tsx @@ -0,0 +1,126 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import type { ThemeVars } from '@coinbase/cds-common/core/theme'; + +import type { Polymorphic } from '../core/polymorphism'; +import { cx } from '../cx'; +import { getStyles } from '../styles/styleProps'; + +import { Box, type BoxBaseProps } from './Box'; + +const DEFAULT_ANGLE = 180; + +export const gradientBoxDefaultElement = 'div'; + +export type GradientBoxDefaultElement = typeof gradientBoxDefaultElement; + +/** + * Configuration for a custom linear gradient. + * This object is transformed to a CSS `linear-gradient()` string internally. + */ +export type LinearGradientConfig = { + /** + * Colors to be distributed along the gradient line. + */ + colors: string[]; + /** + * The relative positions of colors (0 to 1). If supplied, must be the same length as colors. + * @default Evenly distributed + */ + stops?: number[]; + /** + * Gradient angle in degrees. 0 is to top, 90 is to right, 180 is to bottom. + * @default 180 + */ + angle?: number; +}; + +export type GradientConfig = LinearGradientConfig; + +export type GradientBoxBaseProps = BoxBaseProps & { + /** + * Theme gradient preset name. Applied via CSS class. + * Ignored when `gradientConfig` is provided. + * @example "brand", "primary", "positive" + */ + gradient?: ThemeVars.Gradient; + /** + * Custom linear gradient configuration. Applied via inline style. + * Use this for dynamic or non-theme gradients. + * Takes precedence over `gradient` when both are provided. + * @example { colors: ['#0052FF', '#7B3FE4'], angle: 90 } + */ + gradientConfig?: GradientConfig; +}; + +export type GradientBoxProps = Polymorphic.Props< + AsComponent, + GradientBoxBaseProps +>; + +type GradientBoxComponent = (( + props: GradientBoxProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +/** + * Converts a LinearGradientConfig to a CSS linear-gradient string. + * @returns CSS linear-gradient string, or undefined if colors array is empty. + * + * Special handling: + * - Empty colors: Returns undefined (no gradient rendered) + * - Single color: Duplicates the color to create a valid gradient syntax + * that browsers consistently render as a solid color + * - Multiple colors: Creates standard gradient with optional stop positions + */ +const toCSSLinearGradient = (config: LinearGradientConfig): string | undefined => { + const { colors, stops, angle = DEFAULT_ANGLE } = config; + if (colors.length === 0) return undefined; + if (colors.length === 1) { + return `linear-gradient(${angle}deg, ${colors[0]}, ${colors[0]})`; + } + const colorStops = colors + .map((color, index) => { + const stop = stops?.[index]; + return stop !== undefined ? `${color} ${stop * 100}%` : color; + }) + .join(', '); + return `linear-gradient(${angle}deg, ${colorStops})`; +}; + +export const GradientBox: GradientBoxComponent = memo( + forwardRef, GradientBoxBaseProps>( + ( + { as, gradient, gradientConfig, className, style, ...props }: GradientBoxProps, + ref?: Polymorphic.Ref, + ) => { + const Component = (as ?? gradientBoxDefaultElement) satisfies React.ElementType; + + const cssGradient = useMemo( + () => (gradientConfig ? toCSSLinearGradient(gradientConfig) : undefined), + [gradientConfig], + ); + + const inlineStyle = useMemo( + () => ({ + ...(cssGradient ? { backgroundImage: cssGradient } : {}), + ...style, + }), + [cssGradient, style], + ); + + const styles = useMemo(() => getStyles({ gradient }, inlineStyle), [gradient, inlineStyle]); + + return ( + + ); + }, + ), +); + +GradientBox.displayName = 'GradientBox'; diff --git a/packages/web/src/layout/__stories__/GradientBox.stories.tsx b/packages/web/src/layout/__stories__/GradientBox.stories.tsx new file mode 100644 index 0000000000..1e46f18e81 --- /dev/null +++ b/packages/web/src/layout/__stories__/GradientBox.stories.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import { css } from '@linaria/core'; + +import { useTheme } from '../../hooks/useTheme'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultGradientTheme } from '../../themes/gradients'; +import { Text } from '../../typography/Text'; +import { GradientBox } from '../GradientBox'; +import { VStack } from '../VStack'; + +const radialGradientCss = css` + background-image: radial-gradient(circle at center, #0052ff, #7b3fe4); +`; + +const conicGradientCss = css` + background-image: conic-gradient(from 0deg, #ff4d4d, #ffaa00, #00cc66, #0052ff, #7b3fe4, #ff4d4d); +`; + +const animatedGradientCss = css` + background-image: linear-gradient(270deg, #0052ff, #7b3fe4, #00cc66, #0052ff); + background-size: 400% 400%; + animation: gradientShift 5s ease infinite; + + @keyframes gradientShift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } +`; + +export default { + title: 'Components/GradientBox (tsx)', + component: GradientBox, +}; + +export const Default = () => { + const theme = useTheme(); + + return ( + + {/* Theme Gradient Presets using gradient prop */} + + + Theme Gradient Presets (gradient) + + + {(['primary', 'brand', 'positive', 'negative', 'premium'] as const).map((preset) => ( + + + Preset: {preset} + + + ))} + + + + {/* Custom Linear Gradients (using gradientConfig) */} + + Custom Linear Gradients (gradientConfig) + + + + + Horizontal (left to right) + + + + + Vertical (top to bottom) + + + + + Diagonal (135°) + + + + + Custom angle (45°) + + + + + {/* Multiple Color Stops */} + + Multiple Color Stops (gradientConfig) + + + + + Four color stops + + + + + Custom stop positions (0%, 70%, 100%) + + + + + {/* Other Gradient Types (using inline style) */} + + Other Gradient Types (via style prop) + + + + + + + + {/* Custom Gradients via className */} + + Custom Gradients (via className) + + + + + Radial gradient (className) + + + + + Conic (className) + + + + + Animated gradient (className) + + + + + ); +}; + +function RadialGradientExample() { + const theme = useTheme(); + const radialGradient = `radial-gradient(circle, ${theme.color.accentBoldBlue}, ${theme.color.accentBoldPurple})`; + + return ( + + + Radial gradient + + + ); +} + +function ConicGradientExample() { + const theme = useTheme(); + const conicGradient = [ + 'conic-gradient(from 0deg', + theme.color.bgNegative, + theme.color.bgWarning, + theme.color.bgPositive, + theme.color.accentBoldBlue, + theme.color.accentBoldPurple, + `${theme.color.bgNegative})`, + ].join(', '); + + return ( + + + Conic + + + ); +} + +function RepeatingGradientExample() { + const theme = useTheme(); + const blue = theme.color.accentBoldBlue; + const purple = theme.color.accentBoldPurple; + const repeatingGradient = `repeating-linear-gradient(45deg, ${blue}, ${blue} 10px, ${purple} 10px, ${purple} 20px)`; + + return ( + + + Repeating linear gradient + + + ); +} diff --git a/packages/web/src/styles/responsive/base.ts b/packages/web/src/styles/responsive/base.ts index 74475c4fd2..658f2ac7f2 100644 --- a/packages/web/src/styles/responsive/base.ts +++ b/packages/web/src/styles/responsive/base.ts @@ -399,6 +399,24 @@ export const background: Record = { `, } as const; +export const gradient: Record = { + primary: css` + background-image: var(--gradient-primary); + `, + positive: css` + background-image: var(--gradient-positive); + `, + negative: css` + background-image: var(--gradient-negative); + `, + brand: css` + background-image: var(--gradient-brand); + `, + premium: css` + background-image: var(--gradient-premium); + `, +} as const; + export const borderColor: Record = { // Text fg: css` diff --git a/packages/web/src/styles/responsive/desktop.ts b/packages/web/src/styles/responsive/desktop.ts index 568dc47e54..8aadcfe4a8 100644 --- a/packages/web/src/styles/responsive/desktop.ts +++ b/packages/web/src/styles/responsive/desktop.ts @@ -640,6 +640,34 @@ export const background: Record = { `, } as const; +export const backgroundImage: Record = { + primary: css` + @media ${media.desktop} { + background-image: var(--gradient-primary); + } + `, + positive: css` + @media ${media.desktop} { + background-image: var(--gradient-positive); + } + `, + negative: css` + @media ${media.desktop} { + background-image: var(--gradient-negative); + } + `, + brand: css` + @media ${media.desktop} { + background-image: var(--gradient-brand); + } + `, + premium: css` + @media ${media.desktop} { + background-image: var(--gradient-premium); + } + `, +} as const; + export const borderColor: Record = { // Text fg: css` diff --git a/packages/web/src/styles/responsive/phone.ts b/packages/web/src/styles/responsive/phone.ts index 6abcadf389..46973050ae 100644 --- a/packages/web/src/styles/responsive/phone.ts +++ b/packages/web/src/styles/responsive/phone.ts @@ -640,6 +640,34 @@ export const background: Record = { `, } as const; +export const backgroundImage: Record = { + primary: css` + @media ${media.phone} { + background-image: var(--gradient-primary); + } + `, + positive: css` + @media ${media.phone} { + background-image: var(--gradient-positive); + } + `, + negative: css` + @media ${media.phone} { + background-image: var(--gradient-negative); + } + `, + brand: css` + @media ${media.phone} { + background-image: var(--gradient-brand); + } + `, + premium: css` + @media ${media.phone} { + background-image: var(--gradient-premium); + } + `, +} as const; + export const borderColor: Record = { // Text fg: css` diff --git a/packages/web/src/styles/responsive/tablet.ts b/packages/web/src/styles/responsive/tablet.ts index ec1a069d80..09656f4fb0 100644 --- a/packages/web/src/styles/responsive/tablet.ts +++ b/packages/web/src/styles/responsive/tablet.ts @@ -640,6 +640,34 @@ export const background: Record = { `, } as const; +export const backgroundImage: Record = { + primary: css` + @media ${media.tablet} { + background-image: var(--gradient-primary); + } + `, + positive: css` + @media ${media.tablet} { + background-image: var(--gradient-positive); + } + `, + negative: css` + @media ${media.tablet} { + background-image: var(--gradient-negative); + } + `, + brand: css` + @media ${media.tablet} { + background-image: var(--gradient-brand); + } + `, + premium: css` + @media ${media.tablet} { + background-image: var(--gradient-premium); + } + `, +} as const; + export const borderColor: Record = { // Text fg: css` diff --git a/packages/web/src/styles/styleProps.ts b/packages/web/src/styles/styleProps.ts index 54457e7559..68e7072721 100644 --- a/packages/web/src/styles/styleProps.ts +++ b/packages/web/src/styles/styleProps.ts @@ -68,6 +68,7 @@ const stylePropThemeVarMap = { marginEnd: 'Space', marginStart: 'Space', elevation: 'Elevation', + gradient: 'Gradient', } as const satisfies Partial>; /** diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx index 3876bfbb85..3d94b43a09 100644 --- a/packages/web/src/system/Interactable.tsx +++ b/packages/web/src/system/Interactable.tsx @@ -14,16 +14,21 @@ import type { Theme } from '../core/theme'; import { cx } from '../cx'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxBaseProps } from '../layout/Box'; +import { GradientBox, type GradientBoxBaseProps } from '../layout/GradientBox'; import { interactableBackground, + interactableBackgroundGradient, interactableBorderColor, interactableDisabledBackground, + interactableDisabledBackgroundGradient, interactableDisabledBorderColor, interactableHoveredBackground, + interactableHoveredBackgroundGradient, interactableHoveredBorderColor, interactableHoveredOpacity, interactablePressedBackground, + interactablePressedBackgroundGradient, interactablePressedBorderColor, interactablePressedOpacity, } from './interactableCSSProperties'; @@ -84,6 +89,30 @@ const baseCss = css` } `; +const backgroundGradientCss = css` + background-image: var(${interactableBackgroundGradient}); +`; + +const hoveredBackgroundGradientCss = css` + &:hover { + background-image: var(${interactableHoveredBackgroundGradient}); + } +`; + +const pressedBackgroundGradientCss = css` + &:active, + &[aria-pressed='true'] { + background-image: var(${interactablePressedBackgroundGradient}); + } +`; + +const disabledBackgroundGradientCss = css` + &:disabled, + &[aria-disabled='true'] { + background-image: var(${interactableDisabledBackgroundGradient}); + } +`; + const blockCss = css` display: block; width: 100%; @@ -123,7 +152,8 @@ export type InteractableDefaultElement = typeof interactableDefaultElement; * background: '#ffffff', * hoveredBackground: '#f5f5f5', * pressedBackground: '#e0e0e0', - * borderColor: '#cccccc' + * borderColor: '#cccccc', + * backgroundGradient: 'linear-gradient(90deg, #ff0000, #0000ff)', * }} * /> * ``` @@ -152,11 +182,26 @@ export type InteractableBlendStyles = { * @default 0.75 */ disabledOpacity?: number; + /** CSS gradient string for the background. Overridden by `gradientConfig` inline style when both are set. */ + backgroundGradient?: string; + /** CSS gradient string for the background when pressed. */ + pressedBackgroundGradient?: string; + /** CSS gradient string for the background when disabled. */ + disabledBackgroundGradient?: string; + /** CSS gradient string for the background when hovered. */ + hoveredBackgroundGradient?: string; }; +const BLEND_STYLES_GRADIENT_KEYS = [ + 'backgroundGradient', + 'hoveredBackgroundGradient', + 'pressedBackgroundGradient', + 'disabledBackgroundGradient', +] as const satisfies ReadonlyArray; + export type InteractableBaseProps = Polymorphic.ExtendableProps< BoxBaseProps, - { + Pick & { /** Apply class names to the outer container. */ className?: string; /** Background color of the overlay (element being interacted with). */ @@ -207,10 +252,12 @@ export const Interactable: InteractableComponent = forwardRef< as, background = 'transparent', borderColor = background, - borderWidth = 100, + borderWidth, block, className, disabled, + gradient, + gradientConfig, loading, pressed, style, @@ -224,6 +271,11 @@ export const Interactable: InteractableComponent = forwardRef< const Component = (as ?? interactableDefaultElement) satisfies React.ElementType; const theme = useTheme(); + const hasBlendStylesGradient = BLEND_STYLES_GRADIENT_KEYS.some((key) => !!blendStyles?.[key]); + const hasGradient = !!gradient || !!gradientConfig || hasBlendStylesGradient; + + const resolvedBorderWidth = borderWidth ?? (hasGradient ? undefined : 100); + const interactableStyle = useMemo( () => ({ ...getInteractableStyles({ @@ -237,25 +289,33 @@ export const Interactable: InteractableComponent = forwardRef< [style, background, theme, blendStyles, borderColor], ); + const Wrapper = hasGradient ? GradientBox : Box; + return ( - ); @@ -278,6 +338,10 @@ export const getInteractableStyles = ({ const hoveredOpacity = blendStyles?.hoveredOpacity ?? opacityHovered; const pressedOpacity = blendStyles?.pressedOpacity ?? opacityPressed; const disabledOpacity = blendStyles?.disabledOpacity ?? opacityDisabled; + const backgroundGradient = blendStyles?.backgroundGradient; + const hoveredGradient = blendStyles?.hoveredBackgroundGradient; + const pressedGradient = blendStyles?.pressedBackgroundGradient; + const disabledGradient = blendStyles?.disabledBackgroundGradient; return { [interactableBackground]: blendStyles?.background ?? `var(--color-${background})`, @@ -322,5 +386,10 @@ export const getInteractableStyles = ({ colorScheme: theme.activeColorScheme, skipContrastOptimization: true, }), + // Only add the gradient CSS properties if the respective gradient is set + ...(backgroundGradient && { [interactableBackgroundGradient]: backgroundGradient }), + ...(hoveredGradient && { [interactableHoveredBackgroundGradient]: hoveredGradient }), + ...(pressedGradient && { [interactablePressedBackgroundGradient]: pressedGradient }), + ...(disabledGradient && { [interactableDisabledBackgroundGradient]: disabledGradient }), }; }; diff --git a/packages/web/src/system/ThemeProvider.tsx b/packages/web/src/system/ThemeProvider.tsx index 6d2582e0be..ba4f0ebb72 100644 --- a/packages/web/src/system/ThemeProvider.tsx +++ b/packages/web/src/system/ThemeProvider.tsx @@ -63,6 +63,7 @@ export const ThemeProvider = ({ const themeApi = useMemo(() => { const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; + const activeGradientKey = activeColorScheme === 'dark' ? 'darkGradient' : 'lightGradient'; const inverseSpectrumKey = activeColorScheme === 'dark' ? 'lightSpectrum' : 'darkSpectrum'; const inverseColorKey = activeColorScheme === 'dark' ? 'lightColor' : 'darkColor'; @@ -92,6 +93,7 @@ export const ThemeProvider = ({ activeColorScheme: activeColorScheme, spectrum: theme[activeSpectrumKey], color: theme[activeColorKey], + gradient: theme[activeGradientKey], }; }, [theme, activeColorScheme]); diff --git a/packages/web/src/system/__stories__/Interactable.stories.tsx b/packages/web/src/system/__stories__/Interactable.stories.tsx index 28d944aac1..ebfea71a37 100644 --- a/packages/web/src/system/__stories__/Interactable.stories.tsx +++ b/packages/web/src/system/__stories__/Interactable.stories.tsx @@ -4,9 +4,11 @@ import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { Button } from '../../buttons/Button'; import { TextInput } from '../../controls'; import { useTheme } from '../../hooks/useTheme'; -import { VStack } from '../../layout'; +import { HStack, VStack } from '../../layout'; +import { defaultGradientTheme } from '../../themes/gradients'; import { Text } from '../../typography'; import { getInteractableStyles, Interactable } from '../Interactable'; +import { ThemeProvider } from '../ThemeProvider'; export default { title: 'Components/Interactable', @@ -145,3 +147,100 @@ export const GeneratedColorStates = () => { ); }; + +export const GradientStates = () => { + const theme = useTheme(); + return ( + + + Theme preset gradient (gradient prop) + + + + Brand preset + + + + + Premium preset + + + + + Positive preset + + + + + Custom gradient (gradientConfig prop) + + + + Horizontal + + + + + Multi-color diagonal + + + + + Gradient interaction states (blendStyles — hover/press/disabled) + + Hover and press the buttons to see gradient transitions + + + + + Hover / Press changes gradient + + + + + + Disabled gradient + + + + + + ); +}; diff --git a/packages/web/src/system/interactableCSSProperties.ts b/packages/web/src/system/interactableCSSProperties.ts index 7ab949cb51..4458f42160 100644 --- a/packages/web/src/system/interactableCSSProperties.ts +++ b/packages/web/src/system/interactableCSSProperties.ts @@ -1,14 +1,19 @@ export const interactableBorderRadius = '--interactable-border-radius'; export const interactableBackground = '--interactable-background'; export const interactableBorderColor = '--interactable-border-color'; +// TO DO: This is temporary, and subject to change based on new css var naming conventions. +export const interactableBackgroundGradient = '--interactable-bg-gradient'; // Pressed: export const interactablePressedBackground = '--interactable-pressed-background'; export const interactablePressedBorderColor = '--interactable-pressed-border-color'; export const interactablePressedOpacity = '--interactable-pressed-opacity'; +export const interactablePressedBackgroundGradient = '--interactable-pressed-bg-gradient'; // Hovered: export const interactableHoveredBackground = '--interactable-hovered-background'; export const interactableHoveredBorderColor = '--interactable-hovered-border-color'; export const interactableHoveredOpacity = '--interactable-hovered-opacity'; +export const interactableHoveredBackgroundGradient = '--interactable-hovered-bg-gradient'; // Disabled: export const interactableDisabledBackground = '--interactable-disabled-background'; export const interactableDisabledBorderColor = '--interactable-disabled-border-color'; +export const interactableDisabledBackgroundGradient = '--interactable-disabled-bg-gradient'; diff --git a/packages/web/src/themes/gradients/coinbaseGradientTheme.ts b/packages/web/src/themes/gradients/coinbaseGradientTheme.ts new file mode 100644 index 0000000000..63227ac12a --- /dev/null +++ b/packages/web/src/themes/gradients/coinbaseGradientTheme.ts @@ -0,0 +1,48 @@ +// TO DO: This is temporary, and subject to change based on design decisions. +import type { ThemeConfig } from '../../core/theme'; +import { coinbaseTheme } from '../coinbaseTheme'; + +const { lightColor, darkColor } = coinbaseTheme; + +/** + * Coinbase gradient presets for light mode. + * Uses color tokens from the Coinbase theme. + */ +export const coinbaseLightGradient = { + primary: `linear-gradient(180deg, ${lightColor.bgPrimary}, ${lightColor.bgPrimary})`, + positive: `linear-gradient(180deg, ${lightColor.bgPositive}, ${lightColor.bgPositive})`, + negative: `linear-gradient(180deg, ${lightColor.bgNegative}, ${lightColor.bgNegative})`, + brand: `linear-gradient(90deg, ${lightColor.accentBoldBlue}, ${lightColor.accentBoldPurple})`, + premium: `linear-gradient(135deg, ${lightColor.accentBoldPurple} 0%, ${lightColor.accentBoldBlue} 50%, ${lightColor.accentBoldGreen} 100%)`, +} as const satisfies ThemeConfig['lightGradient']; + +/** + * Coinbase gradient presets for dark mode. + * Uses color tokens from the Coinbase theme. + */ +export const coinbaseDarkGradient = { + primary: `linear-gradient(180deg, ${darkColor.bgPrimary}, ${darkColor.bgPrimary})`, + positive: `linear-gradient(180deg, ${darkColor.bgPositive}, ${darkColor.bgPositive})`, + negative: `linear-gradient(180deg, ${darkColor.bgNegative}, ${darkColor.bgNegative})`, + brand: `linear-gradient(90deg, ${darkColor.accentBoldBlue}, ${darkColor.accentBoldPurple})`, + premium: `linear-gradient(135deg, ${darkColor.accentBoldPurple} 0%, ${darkColor.accentBoldBlue} 50%, ${darkColor.accentBoldGreen} 100%)`, +} as const satisfies ThemeConfig['darkGradient']; + +/** + * Coinbase theme with gradient presets enabled. + * A complete theme configuration ready to use with ThemeProvider. + * + * @example + * ```tsx + * import { coinbaseGradientTheme } from '@coinbase/cds-web/themes/gradients'; + * + * + * + * + * ``` + */ +export const coinbaseGradientTheme = { + ...coinbaseTheme, + lightGradient: coinbaseLightGradient, + darkGradient: coinbaseDarkGradient, +} as const satisfies ThemeConfig; diff --git a/packages/web/src/themes/gradients/defaultGradientTheme.ts b/packages/web/src/themes/gradients/defaultGradientTheme.ts new file mode 100644 index 0000000000..b9304731d9 --- /dev/null +++ b/packages/web/src/themes/gradients/defaultGradientTheme.ts @@ -0,0 +1,48 @@ +// TO DO: This is temporary, and subject to change based on design decisions. +import type { ThemeConfig } from '../../core/theme'; +import { defaultTheme } from '../defaultTheme'; + +const { lightColor, darkColor } = defaultTheme; + +/** + * Default gradient presets for light mode. + * Uses color tokens from the default theme. + */ +export const defaultLightGradient = { + primary: `linear-gradient(180deg, ${lightColor.bgPrimary}, ${lightColor.bgPrimary})`, + positive: `linear-gradient(180deg, ${lightColor.bgPositive}, ${lightColor.transparent})`, + negative: `linear-gradient(180deg, ${lightColor.bgNegative}, ${lightColor.bgNegative})`, + brand: `linear-gradient(90deg, ${lightColor.accentBoldBlue}, ${lightColor.accentBoldPurple})`, + premium: `linear-gradient(135deg, ${lightColor.accentBoldPurple} 0%, ${lightColor.accentBoldBlue} 50%, ${lightColor.accentBoldGreen} 100%)`, +} as const satisfies ThemeConfig['lightGradient']; + +/** + * Default gradient presets for dark mode. + * Uses color tokens from the default theme. + */ +export const defaultDarkGradient = { + primary: `linear-gradient(180deg, ${darkColor.bgPrimary}, ${darkColor.bgPrimary})`, + positive: `linear-gradient(180deg, ${darkColor.bgPositive}, ${darkColor.transparent})`, + negative: `linear-gradient(180deg, ${darkColor.bgNegative}, ${darkColor.bgNegative})`, + brand: `linear-gradient(90deg, ${darkColor.accentBoldBlue}, ${darkColor.accentBoldPurple})`, + premium: `linear-gradient(135deg, ${darkColor.accentBoldPurple} 0%, ${darkColor.accentBoldBlue} 50%, ${darkColor.accentBoldGreen} 100%)`, +} as const satisfies ThemeConfig['darkGradient']; + +/** + * Default theme with gradient presets enabled. + * A complete theme configuration ready to use with ThemeProvider. + * + * @example + * ```tsx + * import { defaultGradientTheme } from '@coinbase/cds-web/themes/gradients'; + * + * + * + * + * ``` + */ +export const defaultGradientTheme = { + ...defaultTheme, + lightGradient: defaultLightGradient, + darkGradient: defaultDarkGradient, +} as const satisfies ThemeConfig; diff --git a/packages/web/src/themes/gradients/index.ts b/packages/web/src/themes/gradients/index.ts new file mode 100644 index 0000000000..67d43e2b4b --- /dev/null +++ b/packages/web/src/themes/gradients/index.ts @@ -0,0 +1,11 @@ +export { + defaultLightGradient, + defaultDarkGradient, + defaultGradientTheme, +} from './defaultGradientTheme'; + +export { + coinbaseLightGradient, + coinbaseDarkGradient, + coinbaseGradientTheme, +} from './coinbaseGradientTheme';