Skip to content

[material_ui] Add Material 3 Expressive ElevatedButton - #13084

Merged
auto-submit[bot] merged 23 commits into
flutter:mainfrom
QuncCccccc:elevated_button_m3e_main
Nov 4, 2026
Merged

auto-submit[bot] merged 23 commits into
flutter:mainfrom
QuncCccccc:elevated_button_m3e_main

Conversation

@QuncCccccc

@QuncCccccc QuncCccccc commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Adds Material 3 Expressive support to ElevatedButton. As with IconButton (#12832), apps opt in with ElevatedButtonThemeData(variant: StyleVariant.material3Expressive). Without it, ElevatedButton keeps the Material 3 style.

With Material 3 Expressive, ElevatedButton supports:

  • Sizes (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.
  • Shapes (ButtonStyle.shapeVariant): round and square, including the pressed shape and the shape change when a toggle button is selected.
  • Toggle buttons: a button with a non-null isSelected is selectable, and uses the selected colors and selected semantics when isSelected is true.

The defaults are generated from the Material Design tokens by a new ButtonTemplateM3E in gen_defaults.

New APIs:

  • ElevatedButtonThemeData.variant and ElevatedButtonTheme.variantOf. A theme that doesn't specify variant inherits it from the enclosing themes, the same way as IconButtonThemeData.variant ([material_ui] Inherit IconButtonThemeData.variant from enclosing themes #13263).
  • ButtonStyleButton.isSelected and ElevatedButton.isSelected.
  • sizeVariant and shapeVariant parameters for ElevatedButton.styleFrom.

Material 3 Expressive ElevatedButton sizes, shapes, toggle buttons, and disabled buttons

Fixes flutter/flutter#194202
Part of flutter/flutter#184937

Pre-Review Checklist

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-assist bot 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

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

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.
@QuncCccccc
QuncCccccc force-pushed the elevated_button_m3e_main branch from 66dffeb to 64bab74 Compare October 16, 2026 20:41
@QuncCccccc QuncCccccc added the CICD Run CI/CD label Oct 16, 2026
The hovered container elevation is no longer part of the Material 3 button spec. Expressive elevated buttons use elevation 1 in all enabled states.
@QuncCccccc
QuncCccccc marked this pull request as ready for review October 17, 2026 23:07

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +359 to +361
if (widget.isSelected != null) {
statesController.update(WidgetState.selected, widget.isSelected!);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@QuncCccccc
QuncCccccc requested review from Piinks and elliette October 17, 2026 23:11

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

Just a couple small comments but this looks great, thank you!

);

String get _minimumSizeSwitch => _sizeSwitch(
xSmall: 'const Size(64.0, ${TokenButtonXsmall.containerHeight})',

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.

Are we missing a token for the 64.0 value?

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.

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.

Comment on lines +361 to +365
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})',

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.

[nit] Consider using the TokenButtonX.trailingSpace value as well in case they ever differ.

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.

It makes sense. Added both! Thank you!

@QuncCccccc

Copy link
Copy Markdown
Contributor Author

I'll wait until #13263 lands first. #13263 introduced <component>ThemeData.variantOf which is slightly different than <component>ThemeData.of, and this ElevatedButton PR also use variantOf.

@Piinks Piinks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks great! I left two minor non-blocking nits inline and a note on the bot's comment in button_style_button.dart. LGTM once #13263 lands!

Comment on lines +359 to +361
if (widget.isSelected != null) {
statesController.update(WidgetState.selected, widget.isSelected!);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

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.

Updated both! Thank!

@@ -496,14 +616,23 @@ class _ElevatedButtonWithIconChild extends StatelessWidget {
final double scale =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@QuncCccccc QuncCccccc added the autosubmit Merge PR when tree becomes green via auto submit App label Nov 4, 2026
@auto-submit
auto-submit Bot merged commit 41990f9 into flutter:main Nov 4, 2026
13 checks passed
bkonyi pushed a commit to bkonyi/flutter that referenced this pull request Nov 5, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autosubmit Merge PR when tree becomes green via auto submit App CICD Run CI/CD p: material_ui triage-design Should be looked at in design triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional isSelected toggle state support to ButtonStyleButton

3 participants