feat: modernize Button to MD3 Expressive - #5097
Draft
oleksandrzavarzin-callstack wants to merge 40 commits into
Draft
feat: modernize Button to MD3 Expressive#5097oleksandrzavarzin-callstack wants to merge 40 commits into
oleksandrzavarzin-callstack wants to merge 40 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).
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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Buttonwas left out of the earlier modernization wave, so it still had one size,no shape scale, no toggle, MD2-era mode names, and RN
Animatedanimations. Thisbrings 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
mainhere, and thechildren→labelrename is reverted - the label still goes inchildren.What's new
size- the XS/S/M/L/XL scale. Defaults tosmall, which is the only size5.x had, so existing buttons keep their metrics.
shape-round(full pill) orsquare(a smaller per-size corner).Defaults to
round.selected- the Expressive toggle. Deliberately nullable: leaving itundefinedis a plain button, because MD3 colors a toggle that isunselected differently from a button that is not a toggle at all.
animates between shapes as
selectedchanges. The pressed corner tightens withthe 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 underreduce-motion.
iconPosition-leading/trailing, replacing the undocumentedcontentStyle={{ flexDirection: 'row-reverse' }}trick. Flips withuseLocale()under RTL.
rippleColor- the ripple / state layer, defaulting to the label colour atthe pressed-state opacity per spec.
src/components/Button/tokens.ts, modelled on Compose'sButton{Size}Tokensand resolved through the shared
resolveCornerRadius.is expanded with
hitSlopwithout changing the visual size.theme.motion.spring.fast.spatial. Elevation and container colour rideSurface's CSS transitions, which readtheme.motion.*.Breaking changes
Mode names and default
The default mode is now
filled, nottextRemoved props
uppercase→textTransform.compact→ superseded by the size scale.contentStyle={{ flexDirection: 'row-reverse' }}→iconPosition.styleis aSurfaceStyle, so corner radius moves out of itButtonrenders onSurface, sostyleisStyleProp<SurfaceStyle>and followsthe same rule the Surface rework introduced in #5078: border radius is not set
through
style. Useshape, or override the corner token that the size maps to.The square corner reads
mediumat XS/S,largeat M, andextraLargeat L/XL.Being animated,
stylealso takes Reanimated styles:There is no
elevationprop; elevation followsmode="elevated".Appearance changes, no code change needed
outlinedlabel and icon useonSurfaceVariantinstead ofprimary.labelStyle={{ fontSize }}no longer changes the icon size - it followssize.Card.Actions
Defaults its children to
outlinedfor the first andfilledfor the rest, whereit used to default to
contained. It no longer forwardscompactoruppercase.Follow-ups
TouchableRippleconsumer a focus ring, so Button gets one with no code here - which is why noneis implemented locally. One thing to settle there: it defaults to
focusRing="outward"and warns an outward ring is trimmed by "any clippingancestor sized to its content". This PR wraps Button's ripple in a permanent
overflow: 'hidden'clip view (needed so the ripple follows the animatedcorner), so Button will likely need
focusRing="inward"or the ring on theouter view. There is a
TODOinButton.tsxpointing at it.hitSlopexpansion here and also touches Button's snapshot. Whichever lands second will need a rebase; thetwo approaches should be reconciled rather than stacked.
ToggleButton. Split out of this PR and open separately, soneither depends on the other. It points icon-only toggles at
IconButton'sexisting
selectedprop; theselectedprop added here is the replacement pathfor a toggle that carries a label.
Notes for review
main(outlineVariant).@adrcotfas asked for
outlinein the component review and the original branchmade that change; I reverted it.
OutlinedButtonTokens.OutlineColorin theCompose reference - the implementation the review itself names as the
cross-check - is
OutlineVariant.Review threads
All ten from the previous round:
label, keepchildrenchildrenis byte-identical tomainsizedefaults tosmall, so the concept is goneSurface's transitions. Both read motion tokensuppercaseDialog.ActionscontentStyledoc must not mentionflexDirectionrow-reverseconsole.warnat allmigration.mdCard.Actionsmode default breakmigration.mdAnimated.timingnever.start()edSurfaceprop derived during render, so adisabled/modechange moves it with nothing to chaseTest plan
yarn lint,yarn typecheck,yarn testpass. 55 suites, 727 tests, 1 skipped.selected === undefinedvs
false, the per-size token table, shape radii per size, the pressed radius persize, 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.
text/outlinedlabels.BREAKING CHANGE:
Buttonis redesigned for MD3 Expressive with no deprecationaliases.
mode="contained"→mode="filled",mode="contained-tonal"→mode="tonal".The old values are removed.
modeis nowfilled, nottext.uppercaseis removed. UselabelStyle={{ textTransform: 'uppercase' }}.compactis removed. Usesize="extra-small".contentStyle={{ flexDirection: 'row-reverse' }}no longer moves the icon. UseiconPosition="trailing".styleis aStyleProp<SurfaceStyle>, so it no longer sets border radius and nolonger accepts RN
Animatedvalues. Useshapeor a corner token override forthe radius, and a Reanimated style for animation. There is no
elevationprop.outlinedlabel and icon useonSurfaceVariant; the default icon size is 20dp;labelStyle.fontSizeno longer drives the icon size; the resting corner is thefull pill radius.
Card.Actionsdefaults its children tofilledrather thancontained, and nolonger forwards
compactoruppercase.