Repository navigation
[material_ui] Add Material 3 Expressive ElevatedButton - #13084
Conversation
M3E buttons keep a 48dp touch target even when the visual size is smaller, matching the M3E IconButton defaults. An explicit tapTargetSize in the button or theme style still overrides it.
M3E button sizes come from the spec, so they no longer shrink with the theme's visual density (compact by default on desktop). This also keeps the padded tap target at 48dp. Matches the M3E IconButton defaults. An explicit visualDensity in the button or theme style still overrides it.
66dffeb to
64bab74
Compare
The hovered container elevation is no longer part of the Material 3 button spec. Expressive elevated buttons use elevation 1 in all enabled states.
Material Design keeps padding and spacing at 1x when text is resized: https://m3.material.io/foundations/writing/text-resizing
There was a problem hiding this comment.
Code Review
This pull request adds Material 3 Expressive support and selectable states to ElevatedButton. It introduces an isSelected property to ButtonStyleButton and ElevatedButton, implements the _ElevatedButtonDefaultsM3E default styles, and updates the generator templates and tests. A review comment suggests always updating WidgetState.selected with widget.isSelected ?? false during initialization to ensure any pre-existing selected state in a reused statesController is cleared when isSelected is null.
| if (widget.isSelected != null) { | ||
| statesController.update(WidgetState.selected, widget.isSelected!); | ||
| } |
There was a problem hiding this comment.
Always updating WidgetState.selected using widget.isSelected ?? false (similar to how WidgetState.disabled is updated) ensures that any pre-existing selected state in a reused or custom statesController is explicitly cleared when widget.isSelected is null (i.e., when the button is not selectable). This maintains consistency between the widget's parameter and the controller's state.
statesController.update(WidgetState.selected, widget.isSelected ?? false);There was a problem hiding this comment.
Definitely keep the null check here as you wrote it rather than applying the bot's suggestion. When isSelected is null (non-toggleable), callers like SegmentedButton and custom controller-driven buttons pass a WidgetStatesController with WidgetState.selected already populated before mounting the button. Updating WidgetState.selected to false when isSelected is null would wipe out that initial selected state during initState.
elliette
left a comment
There was a problem hiding this comment.
Just a couple small comments but this looks great, thank you!
| ); | ||
|
|
||
| String get _minimumSizeSwitch => _sizeSwitch( | ||
| xSmall: 'const Size(64.0, ${TokenButtonXsmall.containerHeight})', |
There was a problem hiding this comment.
Are we missing a token for the 64.0 value?
There was a problem hiding this comment.
There's no token for it.
The button labels may vary in length, so I guess that's why material team won't specify a width for buttons. Also from the guideline, the M3E tokens don't define a minimum width. 64.0 is the existing default minimum width of these common buttons (the M3 ElevatedButton default is Size(64, 40)), so I kept it to avoid changing the minimum width when switching to the M3E styles. If developers need to use a different min width, they can set minimumSize in ElevatedButtonThemeData.
| xSmall: 'const EdgeInsets.symmetric(horizontal: ${TokenButtonXsmall.leadingSpace})', | ||
| small: 'const EdgeInsets.symmetric(horizontal: ${TokenButtonSmall.leadingSpace})', | ||
| medium: 'const EdgeInsets.symmetric(horizontal: ${TokenButtonMedium.leadingSpace})', | ||
| large: 'const EdgeInsets.symmetric(horizontal: ${TokenButtonLarge.leadingSpace})', | ||
| xLarge: 'const EdgeInsets.symmetric(horizontal: ${TokenButtonXlarge.leadingSpace})', |
There was a problem hiding this comment.
[nit] Consider using the TokenButtonX.trailingSpace value as well in case they ever differ.
There was a problem hiding this comment.
It makes sense. Added both! Thank you!
| if (widget.isSelected != null) { | ||
| statesController.update(WidgetState.selected, widget.isSelected!); | ||
| } |
There was a problem hiding this comment.
Definitely keep the null check here as you wrote it rather than applying the bot's suggestion. When isSelected is null (non-toggleable), callers like SegmentedButton and custom controller-driven buttons pass a WidgetStatesController with WidgetState.selected already populated before mounting the button. Updating WidgetState.selected to false when isSelected is null would wipe out that initial selected state during initState.
| }); | ||
|
|
||
| @override | ||
| WidgetStateProperty<Color?>? get overlayColor => |
There was a problem hiding this comment.
Nit: Unlike backgroundColor, foregroundColor, and iconColor in this template (and overlayColor in IconButtonTemplateM3E), overlayColor here uses the unselectedPressedStateLayerColor, unselectedHoveredStateLayerColor, and unselectedFocusedStateLayerColor tokens for all non-selected states rather than falling back to pressedStateLayerColor, hoveredStateLayerColor, and focusedStateLayerColor when toggleable is false.
For TokenButtonElevated, the unselected and non-toggleable state-layer color tokens both happen to resolve to TokenColorRole.primary, so the generated output for ElevatedButton is identical today. However, if ButtonTemplateM3E is reused for other button types in follow-up PRs (such as FilledButton, where unselectedPressedStateLayerColor is onSurfaceVariant while pressedStateLayerColor is onPrimary), this would emit the wrong overlay color for non-toggleable buttons.
There was a problem hiding this comment.
Updated both! Thank!
| @@ -496,14 +616,23 @@ class _ElevatedButtonWithIconChild extends StatelessWidget { | |||
| final double scale = | |||
There was a problem hiding this comment.
Nit: In _ElevatedButtonWithIconChild.build, defaultFontSize and scale (which calls MediaQuery.textScalerOf(context)) are computed unconditionally even when useExpressive is true and scale is unused. Computing scale only in the non-expressive branch would avoid registering an unnecessary MediaQuery textScaler dependency when using the expressive variant.
flutter/packages@670b30e...0478162 2026-11-05 [email protected] [material_ui] Document RefreshIndicator mouse behavior on desktop and web (flutter/packages#13286) 2026-11-05 [email protected] [material_ui] Make SearchAnchor.bar respect SearchBarThemeData padding (flutter/packages#13360) 2026-11-04 [email protected] [pointer_interceptor] Use dart.library.js_interop in example export (flutter/packages#13558) 2026-11-04 [email protected] [in_app_purchase_storekit] Use purchase(confirmIn:) for StoreKit 2 purchases (flutter/packages#13410) 2026-11-04 [email protected] [material_ui] Fix dismissal near minimum extent when shouldCloseOnMinExtent (flutter/packages#12545) 2026-11-04 [email protected] [material_ui] Add Material 3 Expressive `ElevatedButton` (flutter/packages#13084) 2026-11-04 [email protected] [go_router] Stop inactive StatefulShellRoute branches from vetoing pops (flutter/packages#12972) 2026-11-04 [email protected] Manual roll Flutter (stable) from abaf9c5 to c197ca5 (14 revisions) (flutter/packages#13542) 2026-11-04 [email protected] [in_app_purchase] Convert example app to Swift (flutter/packages#13556) 2026-11-04 [email protected] Sync release-go_router-18.1.3 to main (flutter/packages#13559) 2026-11-04 [email protected] Sync release-material_ui-1.10.0 to main (flutter/packages#13546) 2026-11-04 [email protected] [camera_android_camerax] Add magic number guideline to `TESTING.md` (flutter/packages#13081) 2026-11-04 [email protected] Sync release-cupertino_ui-1.1.6 to main (flutter/packages#13547) 2026-11-04 [email protected] [file_selector] Convert unit tests to Kotlin (flutter/packages#13102) 2026-11-04 [email protected] Roll Flutter from 9347202 to 3623d2d (25 revisions) (flutter/packages#13555) If this roll has caused a breakage, revert this CL and set the roller to dry run mode using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC [email protected] on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
Adds Material 3 Expressive support to
ElevatedButton. As withIconButton(#12832), apps opt in withElevatedButtonThemeData(variant: StyleVariant.material3Expressive). Without it,ElevatedButtonkeeps the Material 3 style.With Material 3 Expressive,
ElevatedButtonsupports:ButtonStyle.sizeVariant): extra small through extra large. Each size has its own height, padding, icon size, label text style, and gap between the icon and the label.ButtonStyle.shapeVariant): round and square, including the pressed shape and the shape change when a toggle button is selected.isSelectedis selectable, and uses the selected colors and selected semantics whenisSelectedis true.The defaults are generated from the Material Design tokens by a new
ButtonTemplateM3Eingen_defaults.New APIs:
ElevatedButtonThemeData.variantandElevatedButtonTheme.variantOf. A theme that doesn't specifyvariantinherits it from the enclosing themes, the same way asIconButtonThemeData.variant([material_ui] InheritIconButtonThemeData.variantfrom enclosing themes #13263).ButtonStyleButton.isSelectedandElevatedButton.isSelected.sizeVariantandshapeVariantparameters forElevatedButton.styleFrom.Fixes flutter/flutter#194202
Part of flutter/flutter#184937
Pre-Review Checklist
[shared_preferences]///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2