Skip to content

feat(button)!: modernize Button to MD3 Expressive - #5097

Merged
Doberjohn merged 77 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/button-material-3-expressive
Nov 8, 2026
Merged

Doberjohn merged 77 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 had one size, no shape scale, no toggle, MD2 mode names and RN Animated animations. This PR brings it to Material Design 3 Expressive: size, shape, a selected toggle and a corner morph on press. It takes over #4943.

Related issue

Part of #4928

Screenshots / Videos

Android | iOS

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

Test plan

  1. Open the Button example on Android, iOS and web.
  2. Pick large, then press and hold Play me. The corner tightens with a small overshoot.
  3. Turn on selected. The shape flips between round and square.
  4. Pick outlined with selected on, then turn on Disabled. The button keeps a light fill and has no outline.
  5. A toggle has aria-pressed on web and aria-checked on native.

BREAKING CHANGE: Button moves to Material Design 3 Expressive, with renamed modes, a filled default and removed props, and the migration guide lists each change.

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).
@oleksandrzavarzin-callstack oleksandrzavarzin-callstack changed the title feat: modernize Button to MD3 Expressive feat(button)!: modernize Button to MD3 Expressive Nov 6, 2026
@lukewalczak lukewalczak self-assigned this Nov 6, 2026
Comment thread src/components/Button/utils.tsx Resolved
Comment thread src/components/Button/Button.tsx Resolved
Comment thread src/components/Button/Button.tsx Resolved
Comment thread src/components/Button/Button.tsx Resolved
Comment thread src/components/Button/utils.tsx Resolved

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

Thanks @oleksandrzavarzin-callstack for handling that and addressing all comments! 🙌🏽

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

All looks great approved and we can merge after one quick docs fix. The roundness section of the migration guide, merged in from #5183, still lists Button under largeIncreased, and its example is <Button theme={{ shapes: { corner: { largeIncreased: 10 } } }}>. Button no longer reads largeIncreased, so that example does nothing. Could you:

  • drop Button from the largeIncreased row (it stays for SegmentedButtons) and add a line after the table saying that Button is a full pill by default and that shape="square" reads the corner of its size, as in the Button section's Style notes, and
  • change the example to the square shape, e.g. <Button shape="square" theme={{ shapes: { corner: { medium: 10 } } }}> (5 × 2 = 10 at the default size)?

@Doberjohn
Doberjohn merged commit 96af73b into callstack:main Nov 8, 2026
10 checks passed
@oleksandrzavarzin-callstack
oleksandrzavarzin-callstack deleted the feat/button-material-3-expressive branch November 9, 2026 08:24
Doberjohn added a commit that referenced this pull request Nov 9, 2026
…ns (#5196)

Add two lines to the Button appearance changes in the 6.x migration guide: the outlined outline is drawn in outlineVariant instead of outline, also when disabled (5.x used surfaceDisabled), and Banner actions are text buttons instead of compact ones, with 16dp of horizontal padding insteadof 8dp and a 64dp minimum width.

Follow-up to #5097.
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.

7 participants