fix(fab): align MD3 variants and interaction states - #5110
Conversation
Add surface and branded variants, apply web hover elevation, and prevent hidden FABs from retaining focus or actions. Keep large icons at 36dp. BREAKING CHANGE: Replace tonalPrimary, tonalSecondary, and tonalTertiary with primaryContainer, secondaryContainer, and tertiaryContainer. The old variant names are removed without aliases.
| The color presets for `FAB` and `FAB.Extended` now match the MD3 color roles: | ||
| `primary`, `primaryContainer` (default), `secondary`, `secondaryContainer`, | ||
| `tertiary`, `tertiaryContainer`, `surface`, and `branded`. | ||
|
|
||
| If you used an earlier 6.x build, replace `tonalPrimary`, `tonalSecondary`, and | ||
| `tonalTertiary` with `primaryContainer`, `secondaryContainer`, and | ||
| `tertiaryContainer`. The old names have been removed without aliases. This also | ||
| applies to the FAB menu trigger's `variant`. | ||
|
|
||
| Both `surface` and `branded` use `surfaceContainerHigh`. Surface FAB content uses | ||
| `primary`; branded content defaults to `onSurface`. A custom icon source can | ||
| render brand artwork in its own colors. | ||
|
|
||
| On web, hovering a FAB raises its elevation from level 3 to level 4. Focused and | ||
| pressed states use level 3. The large FAB icon remains **36dp**. |
There was a problem hiding this comment.
rewrite this to actual migration instructions. don't include implementation details or what it was in a prerelease version.
| surface: { container: 'surfaceContainerHigh', content: 'primary' }, | ||
| branded: { container: 'surfaceContainerHigh', content: 'onSurface' }, |
There was a problem hiding this comment.
is this actually in the official guidelines?
| } as const satisfies Record<string, Elevation>; | ||
|
|
||
| const variants = { | ||
| surface: { container: 'surfaceContainerHigh', content: 'primary' }, |
There was a problem hiding this comment.
this looks like it's no longer recommended. remove this
Surface FAB color styles are still available, but no longer recommended.
| leading, | ||
| trailing, | ||
| elevation = Tokens.stateElevation.enabled, | ||
| elevation, |
There was a problem hiding this comment.
the elevation prop should be removed if elevation is supposed to be dynamic.
| if (Platform.OS === 'web') { | ||
| touchableRef.current?.blur(); | ||
| } |
There was a problem hiding this comment.
doing this means user's focus is removed from the page and they lose their position. removing focus on toggling visible is defensible, though it should move focus back to previous element. but doing it when onPress becomes undefined seems unnecessary.
| (Platform.OS === 'web' && visible && onPress | ||
| ? pressed | ||
| ? Tokens.stateElevation.pressed | ||
| : focused | ||
| ? Tokens.stateElevation.focus | ||
| : hovered | ||
| ? Tokens.stateElevation.hover | ||
| : Tokens.stateElevation.enabled | ||
| : Tokens.stateElevation.enabled); |
There was a problem hiding this comment.
what's the rationale behind different behavior on web and native?
| : focused | ||
| ? Tokens.stateElevation.focus | ||
| : hovered | ||
| ? Tokens.stateElevation.hover |
There was a problem hiding this comment.
a hovered element can also be focused. focus shouldn't take priority over hover styling.
| onPress={visible ? onPress : undefined} | ||
| disabled={!onPress || !visible} |
There was a problem hiding this comment.
why is this needed? disabled changes how a11y tree sees the button. a hidden button shouldn't be accessible at all rather than being marked as disabled
| * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, | ||
| * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. | ||
| * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary | ||
| * content, while `branded` uses on-surface content. Pass a custom icon source | ||
| * to preserve brand artwork colors. |
| * Choose `primary`, `primaryContainer`, `secondary`, `secondaryContainer`, | ||
| * `tertiary`, `tertiaryContainer`, `surface`, or `branded`. | ||
| * `surface` and `branded` use `surfaceContainerHigh`; `surface` uses primary | ||
| * content, while `branded` uses on-surface content. Pass a custom icon source | ||
| * to preserve brand artwork colors. |
Motivation
FABis one of the MD3 reference components, but a review against the spec found two deviations: the color presets didn't match the MD3 variant set, and the hover-elevation token was defined but never applied.The variant rename is breaking:
tonalPrimary,tonalSecondaryandtonalTertiarybecomeprimaryContainer,secondaryContainerandtertiaryContainer, with no aliases, andsurfaceandbrandedare added. That makes the preset names the MD3 role names, so the mapping is checkable against the spec.This worth mentioning:
md.comp.fab.large.icon.sizeis 36dp on every variant, and material-web's generated_md-comp-fab-branded.scssemitsicon-size: 36px. The widely-quoted 32dp comes from a token androidx annotates as wrong in its own source (LargeIconSize: Dp = 36.dp // TODO: FabLargeTokens.IconSize is incorrect). Stated explicitly because 32dp is easy to find and looks authoritative.branded's content color is a judgement call. material-web defines container and elevation for the branded FAB but noicon-colortoken at all - brand artwork supplies its own colors.onSurfaceis used as the fallback for the label and a monochrome icon; passing a custom icon source keeps brand artwork in its own colors.surfaceneeds no such call: the spec gives itsurface-container-highwith aprimaryicon, which is what it does.Token values were verified against material-web's generated
_md-comp-fab-surface.scssand_md-comp-fab-branded.scss- the spec site is JS-rendered.Related issue
The FAB review checklist:
tonalPrimary/tonalSecondary/tonalTertiary→primaryContainer/secondaryContainer/tertiaryContainer, and add the missingsurfaceandbrandedvariants (bothsurfaceContainerHigh). Delete the old names - no alias.Also included, under the umbrella task's accessibility criterion rather than the checklist: a hidden FAB no longer retains focus, actions, or an accessibility node.
Test plan
yarn lint,yarn typecheckandyarn testpass - 690 tests, 168 snapshots.12 new tests (FAB: 54 → 66) cover every variant's container and content role in both light and dark, hover elevation on web for the icon and extended FABs, that an explicit shell elevation (including a flat menu item) is not overridden, that native platforms stay at the resting elevation, that the menu trigger is unaffected, that adding interaction handlers doesn't enable an actionless FAB, and that hiding a FAB removes it from the accessibility tree, disables its action, and resets its elevation.
Manual, on the FAB example screen:
surfaceandbrandedsharesurfaceContainerHigh, with aprimaryicon and anonSurfaceicon respectively.menuwhilesurfaceorbrandedis selected - the chip set drops both and the variant falls back toprimaryContainer.large- the icon measures 36dp, not 32dp.aria-label; the hidden FAB is not announced.