Repository navigation
feat: modernize Button to MD3 Expressive - #5097
oleksandrzavarzin-callstack wants to merge 59 commits into
Conversation
Introduce a `label?: string` prop as the primary way to set the button text. The `children` prop keeps working as a deprecated fallback (when both are set, `label` wins) and emits a dev-only warning. This decouples the button layout from arbitrary child structures and makes `uppercase` work reliably, since the label is always a string.
Update the components that compose Button (Banner, Snackbar, DataTablePagination) to pass the new `label` prop instead of children, and update the `## Usage` / `@example` JSDoc blocks (and the test files) accordingly so nothing relies on the deprecated `children` prop.
Add an `iconPosition?: 'leading' | 'trailing'` prop to control where the
icon sits relative to the label. The previous approach of setting
`contentStyle={{ flexDirection: 'row-reverse' }}` still works but is now
deprecated and emits a dev-only warning.
The icon margins are extracted into a `getButtonIconStyle` helper,
replacing the previous matrix of computed StyleSheet keys, and
DataTablePagination is updated to use the new prop.
Add a `rippleColor?: ColorValue` prop and, by default, drive the ripple / state layer with the label color at the pressed-state opacity (per Material Design 3) instead of TouchableRipple's onSurface-based default. The color is computed by a new `getButtonRippleColor` helper, which falls back to `undefined` (TouchableRipple's own default) when the label color is not a plain string, e.g. an Android Material You PlatformColor.
Wrap the expensive derived values (color computation, border-radius extraction, ripple color, icon style, touchable ripple style, and the flattened style objects) in `useMemo`, memoize the press handlers with `useCallback`, and replace the `isMode` `useCallback` with a plain local function. No behavior or render-output change.
Update the example screens to use the new `label` prop instead of
children, and the `iconPosition="trailing"` prop instead of the
`contentStyle={{ flexDirection: 'row-reverse' }}` hack.
Update the hand-written guide snippets (icons, react-navigation, ripple effect) and the docs-site example components to use the new `label` prop instead of children. The generated component reference pages are derived from the JSDoc and will be regenerated by the docs build.
Add a `size?: 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'` prop. When omitted, the Button keeps its current visuals; when set, the per-size MD3 metrics (minHeight, horizontal padding, icon size, icon/label gap, label typescale) are applied via a new `getButtonSizeStyle` helper.
Add a `shape?: 'round' | 'square'` prop. When omitted, the button keeps its legacy corner radius. When set, `'round'` uses the full-pill radius and `'square'` uses a per-size smaller corner; the mapping comes from a new `getButtonShapeRadius` helper. An explicit `borderRadius` in `style` still wins.
Add a `selected?: boolean` prop. When `true`, the button flips its `shape` (round ↔ square) so the selected/unselected pair contrasts, and for `outlined`/`text` modes adopts a filled tonal-selected appearance (`secondaryContainer` background, `onSecondaryContainer` label, no border). `accessibilityState.selected` is set so screen readers announce the toggle state. Other modes keep their colors and only flip the shape. The `selected` flag is threaded through `getButtonColors` and its sub-helpers.
Showcase the new expressive props in the example app: one button per size in the Size section, a round and a square row across sizes in the Shape section, and stateful selected/unselected toggles in the Toggle section.
Treat `iconPosition` as logical (reading-order) and swap leading/trailing under RTL. The layout engine already mirrors `flexDirection` and physical margins when the platform direction is RTL (native I18nManager, or react-native-web with an active I18nManager), so we only flip manually when the locale direction disagrees with the platform default — e.g. a web LocaleProvider override where I18nManager is a no-op. This avoids a double-flip on native RTL.
Use a single `label` vocabulary internally to match the public `label` prop and the MD3 spec: - getButtonColors returns labelColor/labelOpacity (was textColor/textOpacity) - getButtonTextColor -> getButtonLabelColor; customTextColor -> customLabelColor - getButtonRippleColor takes labelColor Also drop the md3 prefix from the no-size styles (md3Label* -> legacyLabel*). The public `textColor` prop is unchanged. Internal-only rename; no behavior or snapshot change.
Align the outlined variant with the MD3 spec: - border color: theme.colors.outline (was outlineVariant) - label color: theme.colors.onSurfaceVariant (was primary) text and elevated modes keep the primary accent; the selected-outlined tonal branch is unchanged. Snapshot updates in Menu and DataTable reflect the same outlined Button color change.
For the legacy (no-`size`) button: - icon size 18 -> 20dp - horizontal padding 24 -> 16dp (legacyLabel marginHorizontal) - keep the 8dp icon-label gap: the icon's negative margin is calibrated against the label margin, so the non-text icon margins go -16 -> -8 to compensate for the smaller label margin (otherwise the gap collapses to 0). Snapshot updates in Menu and DataTable reflect the same legacy Button metrics.
Add src/components/Button/tokens.ts with one token object per MD3 expressive
size (modelled on Jetpack Compose's Button{Size}Tokens), replacing the inline
BUTTON_SIZE_STYLES and BUTTON_SHAPE_RADIUS maps. Corner radii now reference
shape keys (full / medium / large / extraLarge) resolved against
theme.shapes.corner instead of magic numbers.
- getButtonSizeStyle derives from the tokens
- getButtonShapeRadius takes theme and resolves corner keys (resolveButtonCorner)
- fix extra-small iconSize 16 -> 20 per MD3 spec
- tokens also carry pressed/selected shape fields for the upcoming shape morph
No metric/radius change other than the extra-small icon size.
Align Button mode names with the MD3 spec. This is a breaking rename (no aliases), matching the v6 breaking-change window: - contained -> filled - contained-tonal -> tonal The resolved styles are unchanged, so there is no visual difference. Updates the Button mode type, internal isMode checks, CardActions' injected default, and all consumers (example, docs, tests). Card/IconButton/SegmentedButtons/ ToggleButton keep their own independent contained modes.
Change the default Button mode from text to filled to match the MD3 emphasis hierarchy (filled is the highest-emphasis, primary button). A bare <Button> now renders filled instead of text. All library-internal usages pass an explicit mode, so none are affected. Snapshots updated for bare buttons (transparent -> primary background, primary -> onPrimary label).
Extra-small (32dp) and small (40dp) buttons are shorter than the 48dp minimum accessible touch target, so expand the press area with hitSlop without changing the visual size (XS -> top/bottom 8, S -> top/bottom 4). A user-supplied hitSlop wins on the axes it sets; a numeric hitSlop is respected as-is. Verified on device that taps inside the slop zone register and taps outside do not.
Shaped buttons animate their corner radius with the theme motion spring: to corner.small (8dp) while pressed, and between the round/square radii when the selected toggle flips the shape. Stability: the animated path resolves round to the real pill radius (minHeight/2) instead of the cornerFull sentinel so the spring stays bounded, plus a >= 0 clamp guards against overshoot. Scoped to shaped buttons that don't pin a radius via style; legacy/size-only buttons keep a static corner. Web: the inner ripple can't follow an Animated value, so it's rendered as a rectangle and the Surface clips it (overflow: hidden) to the morphing radius, keeping the outline and state layer in sync.
Replace the long exhaustive grid with a compact playground: a live Button driven by Chip controls (mode/size/shape/icon-position) and Switch rows (show icon/disabled/loading/selected/compact), with smart gating so a control is never a no-op (icon-position only with an icon; compact only when size is unset). Keeps trimmed showcase sections: Modes, States, Size, Shape, Toggle, Custom.
Update the Button doc data sources to match the v6 API: rename the contained/contained-tonal color + screenshot entries to filled/tonal, set the outlined label to onSurfaceVariant, and switch mode="contained" to mode="filled" in the react-navigation and ripple-effect guides. The Button.mdx page is generated from these sources plus the component JSDoc.
Resolve conflicts between the MD3 expressive Button work and main: - Button: adopt main's aria props (`aria-label`, `role`, `aria-disabled`) and add `aria-selected` for the toggle state; migrate off the removed `utils/forwardRef` to React 19 ref-as-prop, keeping the new `size`, `shape`, `selected`, `iconPosition`, `label` and `rippleColor` props. - Button/utils: drop the now-redundant `as Theme` assertions. - Button tests: migrate to main's RNTL 14 conventions (async `render`, `screen` queries, awaited `fireEvent`, `@jest/globals`) and the `tokens.md.sys.state.opacity` path; snapshots regenerated for the accessibility -> aria rename. - Docs: take main's rewritten GetStartedButtons, drop the docs test main removed, and re-apply the `label` migration to the 6.x icons guide (rename detection had landed it on the frozen 5.x copy).
|
@JKobrynski, I've fixed the conflicts, and pushed the fixes to your comments. |
|
@oleksandrzavarzin-callstack unfortunately we've got conflicts again |
|
@JKobrynski Resolved the conflicts |
JKobrynski
left a comment
There was a problem hiding this comment.
LGTM, all yours @satya164
|
Review by Claude iOSDialog actions render as filled buttons
What happens: both actions are filled purple buttons. "Disagree" sets What should happen: dialog actions are text buttons, as on main. The example dialogs (and the Card "Custom Button styles" buttons ignore the corner override
What happens: "Share" and "Explore" render as full pills. Their What should happen: the card shows buttons with a 12dp corner, which is the point of that example. (via Claude, verify if it makes sense) A disabled, selected outlined toggle loses its container and outline
What happens: the button turns into a bare faded label with no outline and no container. It looks like a disabled text button. What should happen: the button keeps a visible shape when disabled, either the outline or the disabled container fill that the other modes get. (via Claude, verify if it makes sense) AndroidA disabled, selected outlined toggle loses its container and outline Same steps as on iOS. The selected and disabled outlined toggle shows only the faded label. (via Claude, verify if it makes sense) WebDialog actions render as filled buttons Same steps as on iOS. Both actions are filled, and the red "Disagree" label sits on purple. (via Claude, verify if it makes sense) |
satya164
left a comment
There was a problem hiding this comment.
Review by Claude
-
The new
filleddefault changes everyButtonthat passes nomode, and the dialog actions in this repo still rely on the oldtextdefault: the example dialogs inexample/src/Examples/Dialogs/*.tsx, the RTL dialog inexample/src/DrawerItems.tsx, and the usage snippets insrc/components/Dialog/Dialog.tsxandsrc/components/Dialog/DialogActions.tsx. They now render as filled buttons. InDialogWithIcon.tsxthe redtextColorof "Disagree" sits on the purple container and is hard to read. Addmode="text"to those actions, which is the same change the migration guide asks users to make. (via Claude, verify if it makes sense) -
The PR description and its BREAKING CHANGE footer say
Card.Actions"defaults its children to outlined for the first and filled for the rest".CardActions.tsxinjects nomodeat all, so every mode-less button insideCard.Actionsbecomesfilled. The migration guide describes this correctly. The description and footer need the same fix, since the footer ends up in the changelog. (via Claude, verify if it makes sense)
| // Snap on mount; animate when a toggle/shape change moves the resting radius. | ||
| React.useEffect(() => { | ||
| restingRadiusRef.current = restingRadius; | ||
| if (!isRadiusMountedRef.current) { | ||
| isRadiusMountedRef.current = true; | ||
| return; | ||
| } | ||
| if (animateShape) { | ||
| springRadiusTo(restingRadius); | ||
| } else { | ||
| animatedRadius.value = restingRadius; | ||
| } | ||
| }, [restingRadius, animateShape, animatedRadius, springRadiusTo]); |
There was a problem hiding this comment.
The selected and shape change animates through an effect that calls withSpring when restingRadius changes. That is a prop-driven transition between two states, so it should use a Reanimated CSS transition (transitionProperty: 'borderRadius', transitionDuration and transitionTimingFunction from theme.motion), as in Badge.tsx and Checkbox.tsx.
The press morph is the same case. It switches between two states (pressed or not) and is not gesture-driven or scroll-driven motion. Keep pressed in state for every mode, derive the target radius from it, and let the transition animate it. A CSS transition starts from the current value when the target changes mid-flight, so the comment at line 329 ("a press can interrupt it mid-flight, which a duration-based transition can't express") does not hold.
This removes animatedRadius, restingRadiusRef, isRadiusMountedRef, springRadiusTo and this effect. Surface needs borderRadius in its transition properties for the outer container. (via Claude, verify if it makes sense)
| const previousContainerColorRef = React.useRef(containerColor); | ||
| React.useEffect(() => { | ||
| previousContainerColorRef.current = containerColor; | ||
| }, [containerColor]); | ||
|
|
||
| const surfaceTransitionDuration = getButtonTransitionDuration({ | ||
| theme, | ||
| mode, | ||
| disabled, | ||
| dark, | ||
| pressed, | ||
| containerColor, | ||
| previousContainerColor: previousContainerColorRef.current, | ||
| }); |
There was a problem hiding this comment.
This reads previousContainerColorRef.current during render. React does not allow reading refs during render: the value is wrong when a render is thrown away or repeated (concurrent rendering, Strict Mode), and the React Compiler bails out of the component.
The snap exists only because Reanimated fades from the transparent keyword through gray. You can avoid that without any previous value: use the target color at zero alpha for an unfilled container (for example color(colors.inverseSurface).alpha(0).rgb().string() for the unselected outlined toggle), so the fade stays in one hue. Then getButtonTransitionDuration only needs pressed, and the ref and effect can go. (via Claude, verify if it makes sense)
| accessibilityHint={accessibilityHint} | ||
| role={role} | ||
| aria-disabled={disabled} | ||
| aria-selected={toggleSelected} |
There was a problem hiding this comment.
On web this renders aria-selected on a <button>. ARIA does not support aria-selected on the button role, so web screen readers do not announce the toggle state. The Toggle section of the example renders <button aria-selected="true"> and no aria-pressed.
The web pattern for a toggle button is aria-pressed, which react-native-web maps to the DOM. Android already exposes the state as selected, so native can keep aria-selected. Pass aria-pressed on web only (React Native's types do not include it, so it needs a web-only path), and update the selected prop docs that mention aria-selected. (via Claude, verify if it makes sense)
| const getButtonBorderColor = ({ isMode, theme, selected }: BaseProps) => { | ||
| // A selected outlined toggle drops its outline (the filled inverse-surface | ||
| // background takes over as the visual affordance). | ||
| if (selected && isMode('outlined')) { |
There was a problem hiding this comment.
A selected outlined toggle drops its outline here even when it is disabled, and getButtonColors also drops the toggle container when disabled (toggleColors = disabled ? undefined : ...). The disabled selected outlined toggle ends up with no container and no outline, only the faded label, so it looks like a disabled text button. Same on iOS and Android:
Keep the outline while disabled (pass disabled into getButtonBorderColor and getButtonBorderWidth, line 329 needs the same check), or give the disabled selected toggle the disabled container fill:
| if (selected && isMode('outlined')) { | |
| if (selected && !disabled && isMode('outlined')) { |
(via Claude, verify if it makes sense)
| const token = | ||
| getEffectiveButtonShape(shape, selected) === 'round' | ||
| ? selected | ||
| ? t.selectedContainerShapeRound | ||
| : t.containerShapeRound | ||
| : selected | ||
| ? t.selectedContainerShapeSquare | ||
| : t.containerShapeSquare; |
There was a problem hiding this comment.
Optional: selectedContainerShapeRound and selectedContainerShapeSquare hold the same values as containerShapeRound and containerShapeSquare for every size in tokens.ts, so the selected branches here never change the result. The flip happens in getEffectiveButtonShape instead. The MD3 tokens encode the flip themselves (the selected round shape is the square corner, and the selected square shape is full). Pick one place: keep the flip in code and remove the selected* tokens and these branches, or store the spec's selected values and drop getEffectiveButtonShape. Then Button.tsx also stops resolving the flip twice for restingRadius. (via Claude, verify if it makes sense)
| <Button | ||
| mode="contained" | ||
| mode="filled" | ||
| theme={{ shapes: { corner: { largeIncreased: 12 } } }} |
There was a problem hiding this comment.
Button no longer reads corner.largeIncreased, so this override does nothing and both buttons in "Custom Button styles" render as full pills ("Share" on line 112 has the same override):
To keep the example meaningful, use the square shape and its token (and the same on line 112):
| theme={{ shapes: { corner: { largeIncreased: 12 } } }} | |
| shape="square" | |
| theme={{ shapes: { corner: { medium: 12 } } }} |
(via Claude, verify if it makes sense)
| New `size` and `shape` props. `size` defaults to `small`, which matches the | ||
| only size 5.x had, so existing buttons keep their metrics. |
There was a problem hiding this comment.
This is not accurate. On main, the filled, tonal, elevated and outlined labels had 24dp horizontal margins and text had 12dp (16dp with an icon). small uses 16dp padding for every mode, so existing buttons change width. Say that small keeps the 40dp height, and list the padding change under "Appearance changes". (via Claude, verify if it makes sense)
| - **`compact`** → `size="extra-small"`. | ||
| - **`contentStyle={{ flexDirection: 'row-reverse' }}`** → `iconPosition="trailing"`. | ||
| - **`elevation`** → elevation follows `mode="elevated"`. |
There was a problem hiding this comment.
compact only reduced the horizontal margins and the min width. The button stayed about 40dp tall. size="extra-small" is 32dp tall, so this mapping (and the Dialog.Actions note at line 256, which says it keeps the previous behavior) makes the buttons shorter. Say that extra-small also changes the height.
Button also had no elevation prop on main, and its style type already left out elevation, so the elevation bullet lists a removed prop that users cannot be passing. It can go. (via Claude, verify if it makes sense)
| describe('touch target', () => { | ||
| const styleOf = (element: ReturnType<typeof containerOf>) => | ||
| StyleSheet.flatten( | ||
| // eslint-disable-next-line no-restricted-syntax -- TODO: replace TestInstance props access with a user-visible assertion. |
There was a problem hiding this comment.
The new tests read props.style from internal views in 8 places, each with eslint-disable-next-line no-restricted-syntax -- TODO, and walk up from the label with parentOf to reach wrappers that have no test ID. The project test rules ask for user-visible assertions and no assertions on internal style values, and this lint rule exists to stop exactly this. The shape morph tests also mock withSpring and assert its arguments, so they test the implementation and would fail on a switch to CSS transitions even with the same visual result.
Please drop the disables and assert on public output: toHaveStyle on the element found by testID, the accessibility state for selected, and the existing unit tests of getButtonSizeStyle and getButtonShapeRadius for the per-size tables. (via Claude, verify if it makes sense)
|
Found potential problems with the pull request:
|
|
Thank you for the review, @satya164 ! Also I left the |








Motivation
Buttonstill had one size, no shape scale, no toggle, MD2 mode names and RNAnimatedanimations. This PR brings it to Material Design 3 Expressive, perthe gaps listed for Button in the
MD3 Component Review.
It takes over #4943.
Related issue
Closes #4928.
What's new
size:extra-smalltoextra-large. Defaults tosmall.shape:round(pill) orsquare. Defaults toround.selected: the MD3 toggle.undefinedis a plain button. Ignored ontext.selected. It follows the theme's fast spatialspring.
animateShape={false}turns it off.iconPosition(leading/trailing) andrippleColor.src/components/Button/tokens.ts.hitSlopthat brings XS and S up to a 48dp touch target.Breaking changes
The migration guide lists each one with a before and after.
contained→filled,contained-tonal→tonal. The default mode isfilled, nottext.uppercase,compact, and thecontentStylerow-reverse trick.styleis aSurfaceStyle: set the corner withshapeor a corner token.modeinsideCard.Actionsnow rendersfilled.Follow-ups
focusRing="inward".hitSlophere.ToggleButton. Labeled toggles move toselected.Screenshots / Videos
Android | iOS
Screen.Recording.2026-09-03.at.15.45.58.2.mp4
Test plan
large, then press and hold Play me. The corner tightens with asmall overshoot.
selectedon. The shape flips between round and square.elevated→outlined, then turn Disabled on and off. No grayfill or flash appears.
aria-pressed, notaria-selected.BREAKING CHANGE:
Buttonis redesigned for MD3 Expressive.mode="contained"→mode="filled",mode="contained-tonal"→mode="tonal". The defaultmodeisfilled, nottext.uppercase,compactand thecontentStylerow-reverse trick are removed.Use
labelStyle,size="extra-small"andiconPosition="trailing".styleno longer sets the border radius. Useshapeor a corner token.modeinsideCard.Actionsrendersfilledinstead oftext.