Skip to content

feat: modernize Button to MD3 Expressive - #5097

Open
oleksandrzavarzin-callstack wants to merge 50 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/button-material-3-expressive
Open

oleksandrzavarzin-callstack wants to merge 50 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/button-material-3-expressive

Conversation

@oleksandrzavarzin-callstack

@oleksandrzavarzin-callstack oleksandrzavarzin-callstack commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Button was left out of the earlier modernization wave, so it still had one size,
no shape scale, no toggle, MD2-era mode names, and RN Animated animations. This
brings it to Material Design 3 Expressive against the gaps listed for Button in the
MD3 Component Review.

Closes #4928.

This is a takeover of #4943. That branch is rebased onto main here, and the
children → label rename is reverted - the label still goes in children.

What's new

  • size - the XS/S/M/L/XL scale. Defaults to small, which is the only size
    5.x had, so existing buttons keep their metrics.
  • shape - round (full pill) or square (a smaller per-size corner).
    Defaults to round.
  • selected - the Expressive toggle. Deliberately nullable: leaving it
    undefined is a plain button, because MD3 colors a toggle that is
    unselected differently from a button that is not a toggle at all.
    MD3 defines the toggle for the filled, elevated, tonal and outlined styles only,
    so selected is inert on mode="text".
  • Corner morph - the container springs to the pressed corner while held, and
    animates between shapes as selected changes. The pressed corner tightens with
    the size per the spec's corner table: 8dp for XS/S, 12dp for M, 16dp for L/XL.
    animateShape={false} opts out, and the press morph is skipped under
    reduce-motion.
  • iconPosition - leading / trailing, replacing the undocumented
    contentStyle={{ flexDirection: 'row-reverse' }} trick. Flips with useLocale()
    under RTL.
  • rippleColor - the ripple / state layer, defaulting to the label color at
    the pressed-state opacity per spec.
  • Component tokens - every per-size metric now lives in
    src/components/Button/tokens.ts, modelled on Compose's Button{Size}Tokens
    and resolved through the shared resolveCornerRadius.
  • Accessible touch target - XS and S are shorter than 48dp, so the press area
    is expanded with hitSlop without changing the visual size.
  • Reanimated - the corner morph is a Reanimated spring reading
    theme.motion.spring.fast.spatial. Elevation and container color ride
    Surface's CSS transitions, which read theme.motion.*.

Breaking changes

Mode names and default

- <Button mode="contained">Save</Button>
+ <Button mode="filled">Save</Button>

- <Button mode="contained-tonal">Save</Button>
+ <Button mode="tonal">Save</Button>

The default mode is now filled, not text

- <Button>Cancel</Button>
+ <Button mode="text">Cancel</Button>

Removed props

- <Button uppercase compact contentStyle={{ flexDirection: 'row-reverse' }} icon="chevron-right">
-   Next
- </Button>
+ <Button
+   size="extra-small"
+   iconPosition="trailing"
+   icon="chevron-right"
+   labelStyle={{ textTransform: 'uppercase' }}
+ >
+   Next
+ </Button>
  • uppercase → textTransform.
  • compact → superseded by the size scale.
  • contentStyle={{ flexDirection: 'row-reverse' }} → iconPosition.

style is a SurfaceStyle, so corner radius moves out of it

Button renders on Surface, so style is StyleProp<SurfaceStyle> and follows
the same rule the Surface rework introduced in #5078: border radius is not set
through style. Use shape, or override the corner token that the size maps to.

- <Button style={{ borderRadius: 8 }}>Press me</Button>
+ <Button shape="square" theme={{ shapes: { corner: { medium: 8 } } }}>
+   Press me
+ </Button>

The square corner reads medium at XS/S, large at M, and extraLarge at L/XL.
Being animated, style also takes Reanimated styles:

- <Button style={{ transform: [{ scale: rnAnimatedValue }] }}>Press me</Button>
+ <Button style={reanimatedStyle}>Press me</Button>

There is no elevation prop; elevation follows mode="elevated".

Appearance changes, no code change needed

  • outlined label and icon use onSurfaceVariant instead of primary.
  • Default icon size is 20dp, up from 18dp.
  • labelStyle={{ fontSize }} no longer changes the icon size - it follows size.
  • The resting corner is the full pill radius rather than a fixed 20dp.

Card.Actions

Defaults its children to outlined for the first and filled for the rest, where
it used to default to contained. It no longer forwards compact or uppercase.

Follow-ups

  • feat: add MD3 keyboard focus indicators #5084 - MD3 keyboard focus indicators. That PR gives every
    TouchableRipple consumer a focus ring, so Button gets one with no code here - which is why none
    is implemented locally. One thing to settle there: it defaults to
    focusRing="outward" and warns an outward ring is trimmed by "any clipping
    ancestor sized to its content". This PR wraps Button's ripple in a permanent
    overflow: 'hidden' clip view (needed so the ripple follows the animated
    corner), so Button will likely need focusRing="inward" or the ring on the
    outer view. There is a TODO in Button.tsx pointing at it.
  • fix: expand interactive targets to the 48dp minimum #5080 - 48dp touch targets overlaps with the hitSlop expansion here and also touches Button's snapshot. Whichever lands second will need a rebase; the
    two approaches should be reconciled rather than stacked.
  • refactor: remove ToggleButton #5095 - remove ToggleButton. Split out of this PR and open separately, so
    neither depends on the other. It points icon-only toggles at IconButton's
    existing selected prop; the selected prop added here is the replacement path
    for a toggle that carries a label.

Notes for review

  • The outlined border color is unchanged from main (outlineVariant).
    @adrcotfas asked for outline in the component review and the original branch
    made that change; I reverted it. OutlinedButtonTokens.OutlineColor in the
    Compose reference - the implementation the review itself names as the
    cross-check - is OutlineVariant.
  • selected is ignored on mode="text", which MD3 gives no toggle: no toggle
    colors, no shape flip, no aria-selected. It is not type-restricted, though.
    A discriminated union would enforce it, but react-docgen silently drops
    union-member props from the generated tables

Review threads

All ten from the previous round:

Thread Resolution
Remove label, keep children Reverted; children is byte-identical to main
"what makes these legacy?" The unsized path is deleted - size defaults to small, so the concept is gone
Use Reanimated Corner morph is a Reanimated spring; elevation and container color ride Surface's transitions. Both read motion tokens
Drop uppercase Removed, along with its injection from Dialog.Actions
contentStyle doc must not mention flexDirection Rewritten; row-reverse support removed
No deprecation warning for row-reverse Support removed outright - Button now has no console.warn at all
Breaking changes undocumented This description plus migration.md
Warnings fire every render All deprecation warnings are gone
Card.Actions mode default break Documented above and in migration.md
Animated.timing never .start()ed Effect deleted. Elevation is now a Surface prop derived during render, so a disabled/mode change moves it with nothing to chase

Test plan

  • yarn lint, yarn typecheck, yarn test pass. 55 suites, 727 tests, 1 skipped.
  • New tests cover: the toggle color table per mode × state, selected === undefined
    vs false, the per-size token table, shape radii per size, the pressed radius per
    size, the corner morph and its opt-out, the reduce-motion skip, the resting shadow
    per mode, the 48dp target, and that every mode renders at the token height
    including an outlined toggle in both states.
  • Tested on Android/iOS simulator and on the web browser:
    • Android dark mode - no square artifact behind text/outlined labels.
    • Android + iOS - the ripple follows the morphing corner.
    • Every mode the same height at a given size, including an outlined toggle.

Android | IOS

Screen.Recording.2026-09-03.at.15.45.58.2.mp4

BREAKING CHANGE: Button is redesigned for MD3 Expressive with no deprecation
aliases.

  • mode="contained" → mode="filled", mode="contained-tonal" → mode="tonal".
    The old values are removed.
  • The default mode is now filled, not text.
  • uppercase is removed. Use labelStyle={{ textTransform: 'uppercase' }}.
  • compact is removed. Use size="extra-small".
  • contentStyle={{ flexDirection: 'row-reverse' }} no longer moves the icon. Use
    iconPosition="trailing".
  • style is a StyleProp<SurfaceStyle>, so it no longer sets border radius and no
    longer accepts RN Animated values. Use shape or a corner token override for
    the radius, and a Reanimated style for animation. There is no elevation prop.
  • outlined label and icon use onSurfaceVariant; the default icon size is 20dp;
    labelStyle.fontSize no longer drives the icon size; the resting corner is the
    full pill radius.
  • Card.Actions defaults its children to filled rather than contained, and no
    longer forwards compact or uppercase.

azizbecha and others added 30 commits May 23, 2026 23:40
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).
@azizbecha

Copy link
Copy Markdown
Collaborator

Hey @oleksandrzavarzin-callstack, thanks for taking time to continue working on Button component, I was away for a little while haha.

Really happy to see you continuing from where I left off and building on top of my previous commits, appreciated!!

Please let me know if there are any tasks we can share or work on together. I’d be happy to help!

@oleksandrzavarzin-callstack
oleksandrzavarzin-callstack marked this pull request as ready for review September 3, 2026 14:23
@JKobrynski

Copy link
Copy Markdown
Collaborator

#5080
is solving the same 48dp target problem by growing the container. hitSlop here sits on the inner TouchableRipple, and RN's own docs say the touch area never extends past the parent bounds - so I don't think this expands anything. Worth coordinating with that PR?

@oleksandrzavarzin-callstack

Copy link
Copy Markdown
Contributor Author

@JKobrynski, thank you for the mention of it!
styles.clip has overflow: 'hidden' and the per-size minHeight is on the inner content view, so the slop fell outside the touchable's own ancestors and never applied.
I removed it here, snapshots regenerated.

#5080 applies the same hitSlop from inside TouchableRipple and doesn't touch Button.tsx, so Button's clipping ancestor would still swallow it - Button needs the same treatment IconButton got there. Both PRs touch Button's snapshot, so whoever lands second regenerates.

@oleksandrzavarzin-callstack

Copy link
Copy Markdown
Contributor Author

BTW, this doesn't hold up the PR. Button is 40dp on main today with no 48dp target either, so this just puts it back where it was. I'll unclip and pick up the real target as a follow-up once #5080 lands.

Comment on lines +464 to +470
const contentBoxStyle = React.useMemo(
() => ({
minHeight: sizeStyle.minHeight - borderWidth * 2,
paddingStart: sizeStyle.paddingStart - borderWidth,
paddingEnd: sizeStyle.paddingEnd - borderWidth,
gap: sizeStyle.iconGap,
}),

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.

I don't think this actually widens the touch target. React Native's own docs say "the touch area never extends past the parent view bounds", and the touchable's parents here - the clip view and the Surface - are both exactly the button's height, so the extra 8dp has nowhere to go. The test only checks the prop is passed through, so it would pass either way. #5080 grows the container instead, which does work — worth taking that approach?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, I found a way to implement the fix without racing with the #5080.
I used the spec's per-size outer bounds (48/48/64/104/144) rather than a flat 48dp, which only covers XS and S. Verified on device with negative controls on both platforms.

Interaction with the #5080 changes: shouldExpand is gated on hitSlop === undefined, so Button's explicit per-size slop wins and the auto-measure stays the default everywhere else.
Web is still a no-op until the PR's absolutely-positioned target lands, then Button gets it for free

Comment thread src/components/Button/tokens.ts Outdated
iconLabelSpace: 12,
leadingSpace: 48,
trailingSpace: 48,
outlinedOutlineWidth: 1,

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.

The outline gets thicker with the size in MD3 -md.comp.button.large.outlined.outline.width is 2dp and md.comp.button.xlarge.outlined.outline.width is 3dp, but this table has 1 for all five sizes. https://m3.material.io/components/buttons/specs

@JKobrynski

Copy link
Copy Markdown
Collaborator

And we've got conflicts

@oleksandrzavarzin-callstack

oleksandrzavarzin-callstack commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor Author

@JKobrynski, I've fixed the conflicts, and pushed the fixes to your comments.

@JKobrynski

Copy link
Copy Markdown
Collaborator

@oleksandrzavarzin-callstack unfortunately we've got conflicts again

@oleksandrzavarzin-callstack

Copy link
Copy Markdown
Contributor Author

@JKobrynski Resolved the conflicts

@JKobrynski JKobrynski left a comment

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.

LGTM, all yours @satya164

@satya164
satya164 requested a balanced review from Copilot September 15, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@satya164

Copy link
Copy Markdown
Member

Review by Claude

iOS

Dialog actions render as filled buttons

  1. Open the Dialog example and tap "With icon".
  2. Look at the "Disagree" and "Agree" actions.

What happens: both actions are filled purple buttons. "Disagree" sets textColor={Palette.error50}, so its red label sits on purple and is hard to read. The example dialogs pass no mode, so they pick up the new filled default.

What should happen: dialog actions are text buttons, as on main. The example dialogs (and the Dialog, Dialog.Actions, Menu, Modal and Snackbar usage snippets) need mode="text", the same change the migration guide asks users to make. (via Claude, verify if it makes sense)

iOS, Dialog with icon: actions are filled

Card "Custom Button styles" buttons ignore the corner override

  1. Open the Card example and scroll to "Custom Button styles".

What happens: "Share" and "Explore" render as full pills. Their theme={{ shapes: { corner: { largeIncreased: 12 } } }} override has no effect, because Button no longer reads largeIncreased.

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)

iOS, Card example: custom radius is ignored

A disabled, selected outlined toggle loses its container and outline

  1. Open the Button example. In the playground, pick Mode "outlined" and Toggle "selected".
  2. Turn on "Disabled".

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)

iOS, outlined toggle: selected (top), selected and disabled (bottom)

Android

A 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)

Android, outlined toggle: selected (top), selected and disabled (bottom)

Web

Dialog 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)

Web, Dialog with icon: actions are filled

@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

  • The new filled default changes every Button that passes no mode, and the dialog actions in this repo still rely on the old text default: the example dialogs in example/src/Examples/Dialogs/*.tsx, the RTL dialog in example/src/DrawerItems.tsx, and the usage snippets in src/components/Dialog/Dialog.tsx and src/components/Dialog/DialogActions.tsx. They now render as filled buttons. In DialogWithIcon.tsx the red textColor of "Disagree" sits on the purple container and is hard to read. Add mode="text" to those actions, which is the same change the migration guide asks users to make. (via Claude, verify if it makes sense)

    iOS, Dialog with icon: actions are filled

  • 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.tsx injects no mode at all, so every mode-less button inside Card.Actions becomes filled. 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)

Comment on lines +378 to +390
// 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]);

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 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)

Comment on lines +433 to 443
const previousContainerColorRef = React.useRef(containerColor);
React.useEffect(() => {
previousContainerColorRef.current = containerColor;
}, [containerColor]);

const surfaceTransitionDuration = getButtonTransitionDuration({
theme,
mode,
disabled,
dark,
pressed,
containerColor,
previousContainerColor: previousContainerColorRef.current,
});

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 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}

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.

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')) {

@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.

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:

iOS, outlined toggle: selected (top), selected and disabled (bottom)

Android, outlined toggle: selected (top), selected and disabled (bottom)

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:

Suggested change
if (selected && isMode('outlined')) {
if (selected && !disabled && isMode('outlined')) {

(via Claude, verify if it makes sense)

Comment on lines +111 to +118
const token =
getEffectiveButtonShape(shape, selected) === 'round'
? selected
? t.selectedContainerShapeRound
: t.containerShapeRound
: selected
? t.selectedContainerShapeSquare
: t.containerShapeSquare;

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.

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 } } }}

@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.

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):

iOS, Card example: custom radius is ignored

To keep the example meaningful, use the square shape and its token (and the same on line 112):

Suggested change
theme={{ shapes: { corner: { largeIncreased: 12 } } }}
shape="square"
theme={{ shapes: { corner: { medium: 12 } } }}

(via Claude, verify if it makes sense)

Comment on lines +466 to +467
New `size` and `shape` props. `size` defaults to `small`, which matches the
only size 5.x had, so existing buttons keep their metrics.

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 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)

Comment on lines +491 to +493
- **`compact`** → `size="extra-small"`.
- **`contentStyle={{ flexDirection: 'row-reverse' }}`** → `iconPosition="trailing"`.
- **`elevation`** → elevation follows `mode="elevated"`.

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.

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.

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 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)

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.

refactor(button): improve MD3 compliance and modernize content API

5 participants