Skip to content

feat: Card MD3 Expressive improvements - #5116

Open
pawelkata wants to merge 14 commits into
callstack:mainfrom
pawelkata:expressive-card
Open

pawelkata wants to merge 14 commits into
callstack:mainfrom
pawelkata:expressive-card

Conversation

@pawelkata

Copy link
Copy Markdown

Motivation

Card was still on the Paper 5 model: a mode prop, arbitrary children composition, and no first-class support for Material 3 expressive states (hover, focus, press, drag) or emphasis tokens. This made it hard to build Cards that match the Material 3 spec — variant-driven container/outline/elevation colors, state layers, focus indicators, and motion — without consumers hand-rolling that behavior on top of View/Surface.

This PR rewrites Card around the Material 3 contract:

  • Replaces mode + arbitrary children with an explicit variant (filled | elevated | outlined) and deterministic slots (media, header/title+subtitle+leading+trailing, content, actions).
  • Adds a single actionable target model: a Card with an interaction handler becomes one accessible control with button semantics; a Card without one stays a neutral grouping container, leaving independent actions to its actions slot.
  • Introduces Material state layers, keyboard focus indicators, hover/press/drag visuals, and animated elevation/outline transitions driven by a resolveCardVisuals token contract, including MD3-correct color tokens (surfaceContainerHighest for filled, outlineVariant for outlined borders) and real ripple/state-layer feedback via TouchableRipple instead of a bare Pressable.
  • Publishes the v6 Card contract and a Paper 5 → 6 migration guide.

Related issue

Addresses #4929.

Screenshots / Videos

See the new "Expressive Card gallery" example (example/src/Examples/CardExample.tsx) for variants, states, shapes, and light/dark/platform verification, and CardRenderCountExample.tsx for the render-count regression check.

Test plan

  • yarn jest — full suite passes (58 suites / 737 tests), including the rewritten Card, Card.tokens, and Surface unit tests covering variants, states (hover/press/focus/drag/disabled), shapes, accessibility, and motion.
  • yarn typecheck — passes, including the new @ts-expect-error cases asserting the removed mode/children API is rejected at compile time.
  • yarn lint — passes.
  • Manually exercised the CardExample gallery in the example app (variants, custom headers, responsive cover media, disabled/dragged states, light/dark theme, platform-specific pressed/long-press messaging).


type CardVariantProps = FilledCardProps | ElevatedCardProps | OutlinedCardProps;

export type Props = Omit<ViewProps, 'children' | 'style'> &

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#5076 drops React.Children using context but keeps <Card><Card.Content/></Card>. I'm afraid moving every region to a prop goes further than #4954 asks for - could Card take that route instead?

Comment thread src/components/Card/Card.tsx
Comment thread src/components/Card/tokens.ts Outdated
const type =
(hasUnionDeclarations && new Set(declarationTypes).size > 1) ||
!declarations.includes(property)
new Set(declarationTypes).size > 1 || !declarations.includes(property)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes prop types on every component page. Have you checked the others?

Comment thread src/components/Card/CardActions.tsx
@JKobrynski

Copy link
Copy Markdown
Collaborator

And we've got conflicts

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS

  1. Pressed feedback is applied twice. Steps: dark app theme, press and hold the "Filled (default)" Card. What happens: the container goes from gray value 51 to 86 (0 to 255 scale) in one frame. One 10% onSurface layer gives about 68. The TouchableRipple underlay (stateLayerPressed, 10% onSurface) and the Card's own pressed state layer (10% onSurface) both draw. What should happen: one pressed layer at 10%. (via Claude, verify if it makes sense)
  2. State layer changes do not animate. Steps: record the screen at 60 fps and press and release the same Card. What happens: the pressed layer appears and disappears in one frame on press in and on press out. What should happen: a fade over short3 (150 ms) with the standard easing, as the PR describes. (via Claude, verify if it makes sense)

Android

  1. A disabled Card looks enabled. Steps: open the "States, shapes, and omitted slots" section. What happens: the "Disabled action" Card keeps full-contrast title and body text. Only the outline is fainter, so it reads the same as an enabled outlined Card. What should happen: the content is dimmed to the disabled opacity (0.38), like Compose CardDefaults does with disabledContentColor. (via Claude, verify if it makes sense)

Android, light theme: disabled outlined Card keeps full-contrast text

Web

  1. The hover state layer does not cover the media. Steps: hover the "Filled (default)" Card. What happens: the area under the cover image darkens by the 8% hover layer, but the image pixels do not change at all. The state layer is drawn below the slot content, so media hides it. The same applies to focus and dragged layers. What should happen: the state layer covers the whole Card, image included. (via Claude, verify if it makes sense)

Web, light theme: rest (left) and hover (right). Only the area below the image changes.

(via Claude, verify if it makes sense)

@satya164 satya164 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude

Points without a diff line:

  1. The branch conflicts with main. #5076 landed and changed Card.tsx, CardActions.tsx, CardContent.tsx, CardCover.tsx, utils.tsx, the Card tests and CardExample.tsx. The open thread on Card.tsx line 178 about slots versus children needs an answer before the rebase, because main now has the context-based children API.
  2. The Card.Content padding change is a visible migration change that the guide does not mention. In v5 the padding depended on neighbors. Now it is always 16 on all sides, so Card.Content followed by Card.Actions gets 24 between the text and the buttons. (via Claude, verify if it makes sense)

contentStyle,
theme: themeOverrides,
testID,
testID = 'card',

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default testID is still here. The reply on the earlier thread says it was removed, but only the derived -container and -visual IDs went away. #5099 removed default test IDs because they collide. On the example screen, 5 neutral Cards expose the identifier card on iOS and the resource-id card on Android, so getByTestId('card') and E2E selectors match several nodes. The tests in Card.test.tsx that use getByTestId('card') need an explicit testID after this change.

Suggested change
testID = 'card',
testID,

(via Claude, verify if it makes sense)

Comment on lines +533 to 561
const stateLayerAnimatedStyle = useAnimatedStyle(
() => ({
opacity: currentInteractiveVisuals.value.stateLayerOpacity,
transitionDuration,
transitionProperty: ['opacity'],
}),
[currentInteractiveVisuals, transitionDuration]
);
const outlineAnimatedStyle = useAnimatedStyle(() => {
const outlineColor = currentInteractiveVisuals.value.outlineColor;

const total = React.Children.count(children);
const siblings = React.Children.map(children, (child) =>
React.isValidElement(child) && child.type
? typeof child.type !== 'string' && 'displayName' in child.type
? child.type.displayName
: null
: null
return {
borderColor: outlineColor,
opacity: currentInteractiveVisuals.value.outlineOpacity,
transitionDuration,
transitionProperty:
typeof outlineColor === 'string'
? ['borderColor', 'opacity']
: ['opacity'],
};
}, [currentInteractiveVisuals, transitionDuration]);
const focusIndicatorAnimatedStyle = useAnimatedStyle(
() => ({
opacity: disabledState.value ? 0 : focused.value ? 1 : 0,
transitionDuration,
transitionProperty: ['opacity'],
}),
[disabledState, transitionDuration]
);

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These worklets return transitionDuration and transitionProperty, but Reanimated CSS transitions run when React commits new style props. A value that a worklet writes on the UI thread does not start one. I recorded the iOS simulator at 60 fps while pressing the filled Card: the pressed layer appears and disappears in a single frame on press in and press out. So short3, the standard easing, theme.animation.scale and reduced motion have no visible effect on the state layer, the outline and the focus indicator. AnimatedElevationSurface in Surface.tsx has the same shape, so elevation changes also jump.

Since these are changes between fixed states, use React state with Reanimated CSS transitions instead. The comment on line 487 shows how.

The snapshot tests do not catch this because they only assert the style objects. (via Claude, verify if it makes sense)

Comment on lines +811 to +816
<TouchableRipple
{...actionableAccessibilityProps}
ref={touchableRef}
testID={testID}
borderless={false}
hoverColor="transparent"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pressed feedback is drawn twice. TouchableRipple adds its own pressed feedback (the underlay on iOS, the ripple on Android, both stateLayerPressed, which is onSurface at 10%), and the Card state layer adds another 10% onSurface for pressed. On the iOS simulator in the dark theme, the filled Card goes from gray value 51 to 86 while pressed. One 10% layer gives about 68.

hoverColor="transparent" already handles this for hover on web. For press, let one of the two own it. Keeping the ripple is closer to Material on Android, so set the Card pressed state layer opacity to 0 and keep the pressed elevation and outline tokens. The other option is rippleColor and underlayColor set to transparent, which removes the Android ripple. (via Claude, verify if it makes sense)

Comment on lines +798 to +809
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFill,
shapeStyle,
{
backgroundColor: visuals.stateLayerColor,
},
stateLayerTransitionStyle,
stateLayerAnimatedStyle,
]}
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state layer renders before the slot content, so media paints over it. On web, hovering the "Filled (default)" Card darkens only the area below the cover. The image pixels do not change at all. Focus and dragged layers have the same problem. Material draws state layers over the whole component, so render this Animated.View after the content (it already has pointerEvents="none").

Web, light theme: rest (left) and hover (right) (via Claude, verify if it makes sense)

Comment on lines 638 to +641
const content = (
<View style={[styles.innerContainer, contentStyle]}>
{React.Children.map(children, (child, index) =>
React.isValidElement(child)
? React.cloneElement(child as React.ReactElement<any>, {
index,
total,
siblings,
})
: child
)}
<View
style={[styles.content, contentStyle]}
testID={hasPassedTouchHandler ? undefined : testID}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A disabled Card dims only its container or outline. The title, subtitle and content keep full contrast, so the disabled outlined Card on the example screen looks the same as an enabled one with a fainter border. Compose CardDefaults.cardColors, elevatedCardColors and outlinedCardColors set disabledContentColor to the content color at 38%. Apply the disabled opacity to this content wrapper when isDisabled is true, or at least to the header that Card renders from title and subtitle.

Android, light theme: disabled outlined Card keeps full-contrast text (via Claude, verify if it makes sense)

Comment on lines +14 to +20
jest.mock('react', () => jest.requireActual('../../../../node_modules/react'));
jest.mock('react-native-reanimated', () =>
jest.requireActual('../../../../node_modules/react-native-reanimated')
);
jest.mock('react-native-paper', () =>
jest.requireActual('../../../../src/index')
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suite fails to run in a clean checkout:

Cannot find module 'react-native-paper' from 'example/src/Examples/__tests__/CardRenderCountExample.test.tsx'

yarn test ends with 1 failed suite (57 suites, not the 58 in the PR description). jest.mock resolves the module name before it uses the factory, and nothing maps react-native-paper at the repo root. The manual mocks of react and react-native-reanimated by relative path also work around the example workspace resolution. Card.test.tsx already has "does not rerender stable memoized slot content for transient feedback", which checks the same behavior through the public API. Remove this file, or move any extra case into Card.test.tsx. (via Claude, verify if it makes sense)


Paper 6 warns in development if whole-Card interaction handlers and a populated `actions` slot are combined. A neutral Card remains a grouping container unless you provide accessibility semantics explicitly. The `dragged` prop controls the Material dragged presentation only; drag gestures and lifecycle remain application responsibilities.

Card refs and test IDs now target documented nodes: `ref` targets the outer shell, `touchableRef` targets the actionable interaction node, and `testID` targets the interaction node for actionable Cards or the slot-content node for neutral Cards. `${testID}-container` and `${testID}-visual` identify the outer shell and clipped visual region.

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

efe5d34 removed the derived IDs, so the last sentence describes IDs that Card no longer renders.

Suggested change
Card refs and test IDs now target documented nodes: `ref` targets the outer shell, `touchableRef` targets the actionable interaction node, and `testID` targets the interaction node for actionable Cards or the slot-content node for neutral Cards. `${testID}-container` and `${testID}-visual` identify the outer shell and clipped visual region.
Card refs and test IDs now target documented nodes: `ref` targets the outer shell, `touchableRef` targets the actionable interaction node, and `testID` targets the interaction node for actionable Cards or the slot-content node for neutral Cards.

(via Claude, verify if it makes sense)

title={title}
content={
<Card.Content>
<Text variant="titleLarge">{title}</Text>

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Card now gets title={title}, so this line renders the title a second time inside the content.

Suggested change
<Text variant="titleLarge">{title}</Text>

(via Claude, verify if it makes sense)

Comment on lines +39 to 44
// styles are guarded with a snapshot of the rendered tree instead.
const expectVisualSnapshot = () => {
expect(screen.toJSON()).toMatchSnapshot();
};

afterEach(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most visual tests assert through this helper, which produces an 11,612-line snapshot file. The test rules ask to avoid assertions on exact internal style values, and a snapshot of the whole tree is that, at a size nobody can review. It also needed the jest/expect-expect change in eslint.config.mjs. The token matrix already has direct tests in Card.tokens.test.ts. Keep a few small snapshots for the variants and assert behavior (roles, disabled state, callbacks) for the rest. (via Claude, verify if it makes sense)

Comment on lines +275 to +298
<View
style={[
styles.verification,
{ borderColor: theme.colors.outlineVariant },
]}
>
<Text variant="titleMedium">Platform verification</Text>
<Text variant="bodySmall">
Android · iOS · web: compare layout, both themes, surface roles,
outlines, clipping, and elevation.
</Text>
<Text variant="bodySmall">
Web: use Tab and hover on actionable Cards to inspect focus and state
layers.
</Text>
<Text variant="bodySmall">
Native: touch actionable Cards to inspect bounded ripple, clipping,
and elevation. Current platform: {Platform.OS}.
</Text>
</View>

<View style={styles.section}>
<CardRenderCountExample />
</View>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example app ships to the app stores now (#5118). The "Platform verification" block gives review instructions ("use Tab and hover ... to inspect focus and state layers"), and CardRenderCountExample is a render-count benchmark. Both are QA tools, not Card demos. Move them out of the gallery. The previous example also had a variant switcher and covered more layouts, such as a cover with only actions and custom button styles. (via Claude, verify if it makes sense)

@satya164 satya164 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude

See the inline comments. (via Claude, verify if it makes sense)

Comment on lines +483 to +487
const disabledState = useSharedValue(isDisabled);
const draggedState = useSharedValue(dragged);
const hovered = useSharedValue(false);
const focused = useSharedValue(false);
const pressed = useSharedValue(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hover, focus, press, drag and disabled are changes between fixed states. Nothing here follows a gesture or a scroll position. So these fit Reanimated CSS transitions with React state, as in Checkbox and Badge on main, better than shared values. This replaces the earlier suggestion to call withTiming inside the worklets.

Keep hovered, focused and pressed in useState, pick the visuals during render, and pass plain styles:

const [pressed, setPressed] = React.useState(false);

const current = isDisabled
  ? disabledVisuals
  : dragged
    ? draggedVisuals
    : pressed
      ? pressedVisuals
      : focused
        ? focusedVisuals
        : hovered
          ? hoveredVisuals
          : enabledVisuals;

<Animated.View
  style={[
    styles.stateLayer,
    {
      opacity: current.stateLayerOpacity,
      transitionProperty: 'opacity',
      transitionDuration,
      transitionTimingFunction,
    },
  ]}
/>

The transitions then start when React commits the new style, disabled and dragged need no effect to copy them, and the shared values and useDerivedValue go away. The cost is one Card render per state change. (via Claude, verify if it makes sense)

* A Reanimated shared value can drive elevation without a React render.
*/
elevation?: Elevation;
elevation?: Elevation | SharedValue<Elevation>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With React state in Card, Card can pass a plain elevation here. Surface on main already runs background, elevation and shadow changes through CSS transitions, so a state change in Card animates with no extra code. An elevation change on hover or press is a state transition, not gesture or scroll driven motion, so it does not need a shared value. Please drop SharedValue<Elevation> and AnimatedElevationSurface. That path also skips the CSS transitions, as the comment on Card.tsx notes. (via Claude, verify if it makes sense)

borderRadiusStyles,
});
const CardCover = ({ style, theme: _theme, ...rest }: Props) => {
const usesAspectRatio = StyleSheet.flatten(style)?.aspectRatio !== undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flattens the whole style prop on every render to read one value, and the layout now depends on what the style happens to contain. A dedicated prop states the intent and needs no StyleSheet.flatten:

const CardCover = ({ aspectRatio, style, theme: _theme, ...rest }: Props) => (
  <Image
    {...rest}
    style={[
      styles.image,
      aspectRatio === undefined ? styles.defaultHeight : { aspectRatio },
      style,
    ]}
    accessibilityIgnoresInvertColors
  />
);

The prop docs, the usage example and the migration guide would then use aspectRatio={16 / 9}. (via Claude, verify if it makes sense)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants