diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx
index acf7ab11d..45108b3a8 100644
--- a/packages/mobile/src/cells/Cell.tsx
+++ b/packages/mobile/src/cells/Cell.tsx
@@ -104,7 +104,24 @@ export const Cell = memo(function Cell({
accessory,
accessoryNode,
alignItems = 'center',
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
borderRadius = 200,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
children,
styles,
end,
@@ -143,9 +160,55 @@ export const Cell = memo(function Cell({
const { marginX: innerSpacingMarginX, ...innerSpacingWithoutMarginX } = innerSpacing;
+ // Border props must be applied to the internal Pressable wrapper for correct visual rendering.
+ // The outer Box was only meant to create padding outside the Pressable area; this behavior
+ // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell.
+ const borderProps = useMemo(
+ () => ({
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
+ borderRadius,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
+ }),
+ [
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
+ borderRadius,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
+ ],
+ );
+
const content = useMemo(() => {
const contentContainerProps = {
- borderRadius,
+ ...borderProps,
testID,
renderToHardwareTextureAndroid: disabled,
...(selected ? { background } : {}),
@@ -233,7 +296,7 @@ export const Cell = memo(function Cell({
);
}, [
- borderRadius,
+ borderProps,
testID,
disabled,
selected,
@@ -281,7 +344,7 @@ export const Cell = memo(function Cell({
accessibilityState={{ disabled, ...accessibilityState }}
background="bg"
blendStyles={blendStyles}
- borderRadius={borderRadius}
+ {...borderProps}
contentStyle={pressStyles}
disabled={disabled}
onPress={onPress}
@@ -304,7 +367,7 @@ export const Cell = memo(function Cell({
styles?.pressable,
accessibilityState,
blendStyles,
- borderRadius,
+ borderProps,
]);
return (
diff --git a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx
index cb9d29d7f..180206cad 100644
--- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx
+++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx
@@ -794,6 +794,77 @@ const WithHelperText = () => (
>
);
+const BorderCustomization = () => {
+ const [isCondensed, setIsCondensed] = useState(true);
+ const spacingVariant = isCondensed ? 'condensed' : 'normal';
+
+ return (
+
+ setIsCondensed(Boolean(nextChecked))}
+ >
+ Spacing variant: {spacingVariant}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
const CustomSpacing = () => (
<>
{
+
+
+
diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx
index c03b99581..c2761751d 100644
--- a/packages/web/src/cells/Cell.tsx
+++ b/packages/web/src/cells/Cell.tsx
@@ -198,7 +198,24 @@ export const Cell: CellComponent = memo(
accessory,
accessoryNode,
alignItems = 'center',
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
borderRadius = 200,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
children,
style,
styles,
@@ -255,11 +272,56 @@ export const Cell: CellComponent = memo(
const isButton = Boolean(onClick ?? onKeyDown ?? onKeyUp);
const linkable = isAnchor || isButton;
const contentTruncationStyle = cx(baseCss, shouldTruncate && truncationCss);
+ // Border props must be applied to the internal Pressable wrapper for correct visual rendering.
+ // The outer Box was only meant to create padding outside the Pressable area; this behavior
+ // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell.
+ const borderProps = useMemo(
+ () => ({
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
+ borderRadius,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
+ }),
+ [
+ bordered,
+ borderedBottom,
+ borderedEnd,
+ borderedHorizontal,
+ borderedStart,
+ borderedTop,
+ borderedVertical,
+ borderBottomLeftRadius,
+ borderBottomRightRadius,
+ borderBottomWidth,
+ borderColor,
+ borderEndWidth,
+ borderRadius,
+ borderStartWidth,
+ borderTopLeftRadius,
+ borderTopRightRadius,
+ borderTopWidth,
+ borderWidth,
+ ],
+ );
const content = useMemo(() => {
// props for the entire inner container that wraps the top content
// (media, children, intermediary, detail, accessory) and the bottom content
const contentContainerProps = {
- borderRadius,
+ ...borderProps,
className: cx(contentClassName, classNames?.contentContainer),
testID,
...(selected ? { background } : {}),
@@ -361,7 +423,7 @@ export const Cell: CellComponent = memo(
);
}, [
- borderRadius,
+ borderProps,
contentClassName,
classNames?.contentContainer,
classNames?.topContent,
@@ -410,7 +472,7 @@ export const Cell: CellComponent = memo(
accessibilityLabel,
accessibilityLabelledBy,
background: 'bg' as const,
- borderRadius,
+ ...borderProps,
className: cx(pressCss, insetFocusRingCss, classNames?.pressable),
disabled,
marginX: innerSpacingMarginX,
@@ -440,7 +502,7 @@ export const Cell: CellComponent = memo(
accessibilityHint,
accessibilityLabel,
accessibilityLabelledBy,
- borderRadius,
+ borderProps,
classNames?.pressable,
disabled,
innerSpacingMarginX,
diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx
index 67d664f5a..9bc9c0900 100644
--- a/packages/web/src/cells/__stories__/ListCell.stories.tsx
+++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx
@@ -883,6 +883,94 @@ const WithHelperText = () => (
);
+const BorderCustomization = () => {
+ const [isCondensed, setIsCondensed] = useState(true);
+ const spacingVariant = isCondensed ? 'condensed' : 'normal';
+
+ return (
+
+ setIsCondensed(event.currentTarget.checked)}
+ >
+ Spacing variant: {spacingVariant}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
const SpacingVariant = () => (
{/* Preferred (new design) */}
@@ -1233,6 +1321,7 @@ const UseCaseShowcase = () => {
};
export {
+ BorderCustomization,
CompactContentDeprecated,
CompactPressableContentDeprecated,
CondensedListCell,
diff --git a/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx
new file mode 100644
index 000000000..d289c66f3
--- /dev/null
+++ b/packages/web/src/hooks/__tests__/useResolveResponsiveProp.test.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+
+import { media } from '../../styles/media';
+import { MediaQueryContext } from '../../system/MediaQueryProvider';
+import { useResolveResponsiveProp } from '../useResolveResponsiveProp';
+
+const createMediaContext = (width: number) => ({
+ subscribe: () => () => {},
+ getServerSnapshot: (query: string) => {
+ if (query === media.phone) return width <= 767;
+ if (query === media.tablet) return width >= 768 && width <= 1279;
+ if (query === media.desktop) return width >= 1280;
+ return false;
+ },
+ getSnapshot: (query: string) => {
+ if (query === media.phone) return width <= 767;
+ if (query === media.tablet) return width >= 768 && width <= 1279;
+ if (query === media.desktop) return width >= 1280;
+ return false;
+ },
+});
+
+const responsiveValue = {
+ base: 'base',
+ phone: 'phone',
+ tablet: 'tablet',
+ desktop: 'desktop',
+} as const;
+
+const wrapper = (width: number) =>
+ function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+
+describe('useResolveResponsiveProp', () => {
+ it('returns scalar value unchanged', () => {
+ const { result } = renderHook(() => useResolveResponsiveProp('scalar'), {
+ wrapper: wrapper(500),
+ });
+ expect(result.current).toBe('scalar');
+ });
+
+ it('returns undefined for undefined input', () => {
+ const { result } = renderHook(() => useResolveResponsiveProp(undefined), {
+ wrapper: wrapper(500),
+ });
+ expect(result.current).toBeUndefined();
+ });
+
+ it.each([
+ ['phone', 500, 'phone'],
+ ['tablet', 900, 'tablet'],
+ ['desktop', 1400, 'desktop'],
+ ])('resolves responsive object for %s viewport', (_, width, expected) => {
+ const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue), {
+ wrapper: wrapper(width),
+ });
+ expect(result.current).toBe(expected);
+ });
+
+ it('falls back to base when outside MediaQueryProvider', () => {
+ const { result } = renderHook(() => useResolveResponsiveProp(responsiveValue));
+ expect(result.current).toBe('base');
+ });
+});
diff --git a/packages/web/src/hooks/useResolveResponsiveProp.ts b/packages/web/src/hooks/useResolveResponsiveProp.ts
new file mode 100644
index 000000000..e144b3637
--- /dev/null
+++ b/packages/web/src/hooks/useResolveResponsiveProp.ts
@@ -0,0 +1,39 @@
+import { useContext } from 'react';
+
+import { media } from '../styles/media';
+import type { ResponsiveProp, ResponsiveValue } from '../styles/styleProps';
+import { MediaQueryContext } from '../system/MediaQueryProvider';
+
+const isResponsiveValue = (value: ResponsiveProp): value is ResponsiveValue =>
+ typeof value === 'object' &&
+ value !== null &&
+ ('base' in value || 'phone' in value || 'tablet' in value || 'desktop' in value);
+
+/**
+ * Resolves a ResponsiveProp to a single value based on the current viewport.
+ *
+ * Use this when you need the resolved value in JavaScript (e.g., passing to a child
+ * component or using in conditional logic). For applying responsive styles via CSS,
+ * use getStyles from styleProps instead—it handles responsive objects via
+ * media-query CSS variables.
+ *
+ * Reads getSnapshot from MediaQueryContext when within MediaQueryProvider.
+ * Without it, returns the first defined value (base ?? phone ?? tablet ?? desktop).
+ *
+ * @param value - A scalar value or responsive object with base/phone/tablet/desktop keys
+ * @returns The resolved value for the current breakpoint
+ */
+export const useResolveResponsiveProp = (
+ value: ResponsiveProp | undefined,
+): T | undefined => {
+ const context = useContext(MediaQueryContext);
+ const getSnapshot = context?.getSnapshot;
+
+ if (!value || !isResponsiveValue(value)) return value;
+ const fallback = value.base ?? value.phone ?? value.tablet ?? value.desktop;
+ if (!getSnapshot) return fallback;
+ if (typeof value.phone !== 'undefined' && getSnapshot(media.phone)) return value.phone;
+ if (typeof value.tablet !== 'undefined' && getSnapshot(media.tablet)) return value.tablet;
+ if (typeof value.desktop !== 'undefined' && getSnapshot(media.desktop)) return value.desktop;
+ return fallback;
+};
diff --git a/packages/web/src/styles/styleProps.ts b/packages/web/src/styles/styleProps.ts
index 54457e755..7c772abfb 100644
--- a/packages/web/src/styles/styleProps.ts
+++ b/packages/web/src/styles/styleProps.ts
@@ -150,7 +150,9 @@ export const dynamicPixelProps = {
flexBasis: 1,
} as const satisfies Partial>;
-export type ResponsiveProp = T | { base?: T; phone?: T; tablet?: T; desktop?: T };
+export type ResponsiveValue = { base?: T; phone?: T; tablet?: T; desktop?: T };
+
+export type ResponsiveProp = T | ResponsiveValue;
export type ResponsiveProps = {
[key in keyof T]?: ResponsiveProp;
diff --git a/packages/web/src/system/Interactable.tsx b/packages/web/src/system/Interactable.tsx
index 3876bfbb8..34c227b19 100644
--- a/packages/web/src/system/Interactable.tsx
+++ b/packages/web/src/system/Interactable.tsx
@@ -12,6 +12,7 @@ import { css } from '@linaria/core';
import type { Polymorphic } from '../core/polymorphism';
import type { Theme } from '../core/theme';
import { cx } from '../cx';
+import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp';
import { useTheme } from '../hooks/useTheme';
import { Box, type BoxBaseProps } from '../layout/Box';
@@ -163,8 +164,6 @@ export type InteractableBaseProps = Polymorphic.ExtendableProps<
background?: ThemeVars.Color;
/** Set element to block and expand to 100% width. */
block?: boolean;
- /** Border color of the element. */
- borderColor?: ThemeVars.Color;
/** Is the element currently disabled. */
disabled?: boolean;
/**
@@ -223,6 +222,7 @@ export const Interactable: InteractableComponent = forwardRef<
) => {
const Component = (as ?? interactableDefaultElement) satisfies React.ElementType;
const theme = useTheme();
+ const resolvedBorderColor = useResolveResponsiveProp(borderColor);
const interactableStyle = useMemo(
() => ({
@@ -230,11 +230,11 @@ export const Interactable: InteractableComponent = forwardRef<
theme,
background,
blendStyles,
- borderColor,
+ borderColor: resolvedBorderColor,
}),
...style,
}),
- [style, background, theme, blendStyles, borderColor],
+ [style, background, theme, blendStyles, resolvedBorderColor],
);
return (
diff --git a/packages/web/src/system/__tests__/Interactable.test.tsx b/packages/web/src/system/__tests__/Interactable.test.tsx
new file mode 100644
index 000000000..808884baf
--- /dev/null
+++ b/packages/web/src/system/__tests__/Interactable.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen } from '@testing-library/react';
+
+import { media } from '../../styles/media';
+import { defaultTheme } from '../../themes/defaultTheme';
+import { Interactable } from '../Interactable';
+import { MediaQueryContext } from '../MediaQueryProvider';
+import { ThemeProvider } from '../ThemeProvider';
+
+const responsiveBorderColor = {
+ base: 'bgLine',
+ phone: 'bgNegative',
+ tablet: 'bgPositive',
+ desktop: 'bgPrimary',
+} as const;
+
+const renderWithWidth = (width?: number) => {
+ const mediaContextValue = width
+ ? {
+ subscribe: () => () => undefined,
+ getServerSnapshot: () => false,
+ getSnapshot: (query: string) => {
+ if (query === media.phone) return width <= 767;
+ if (query === media.tablet) return width >= 768 && width <= 1279;
+ if (query === media.desktop) return width >= 1280;
+ return false;
+ },
+ }
+ : null;
+
+ const content = (
+
+
+
+ );
+
+ return render(
+ mediaContextValue ? (
+ {content}
+ ) : (
+ content
+ ),
+ );
+};
+
+describe('Interactable', () => {
+ it.each([
+ ['phone', 500, 'bgNegative'],
+ ['tablet', 900, 'bgPositive'],
+ ['desktop', 1400, 'bgPrimary'],
+ ])('resolves %s borderColor from responsive prop', (_, width, expectedToken) => {
+ renderWithWidth(width);
+ const element = screen.getByTestId('interactable');
+ expect(element.style.getPropertyValue('--interactable-border-color')).toBe(
+ `var(--color-${expectedToken})`,
+ );
+ });
+
+ it('falls back to base value without MediaQueryProvider', () => {
+ renderWithWidth();
+
+ const element = screen.getByTestId('interactable');
+ expect(element.style.getPropertyValue('--interactable-border-color')).toBe(
+ `var(--color-${responsiveBorderColor.base})`,
+ );
+ });
+});