Skip to content

fix(fab): align MD3 variants and interaction states - #5110

Open
likevy wants to merge 1 commit into
callstack:mainfrom
likevy:fix/fab-md3-spec-accuracy
Open

fix(fab): align MD3 variants and interaction states#5110
likevy wants to merge 1 commit into
callstack:mainfrom
likevy:fix/fab-md3-spec-accuracy

Conversation

@likevy

@likevy likevy commented Sep 8, 2026

Copy link
Copy Markdown

Motivation

FAB is 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, tonalSecondary and tonalTertiary become primaryContainer, secondaryContainer and tertiaryContainer, with no aliases, and surface and branded are added. That makes the preset names the MD3 role names, so the mapping is checkable against the spec.

This worth mentioning:

  • The large FAB icon stays 36dp, deliberately. md.comp.fab.large.icon.size is 36dp on every variant, and material-web's generated _md-comp-fab-branded.scss emits icon-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 no icon-color token at all - brand artwork supplies its own colors. onSurface is used as the fallback for the label and a monochrome icon; passing a custom icon source keeps brand artwork in its own colors. surface needs no such call: the spec gives it surface-container-high with a primary icon, which is what it does.
  • Hover elevation is web-only, and the FAB Menu is deliberately excluded.
  • A hidden FAB no longer keeps focus or its action.

Token values were verified against material-web's generated _md-comp-fab-surface.scss and _md-comp-fab-branded.scss - the spec site is JS-rendered.

Related issue

The FAB review checklist:

  • Rename the color variants to match the m3 set: tonalPrimary/tonalSecondary/tonalTertiaryprimaryContainer/secondaryContainer/tertiaryContainer, and add the missing surface and branded variants (both surfaceContainerHigh). Delete the old names - no alias.
  • The large FAB icon size is unchanged at 36dp, and a test pins every variant's resolved colors in both schemes so the set can't drift.
  • The hover-elevation token (elevation level 4) is defined but never applied on web — wire it through the interaction state. (FAB Menu behavior/web is a separate task.)

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 typecheck and yarn test pass - 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:

  1. Cycle the Color chips - all eight variants render; surface and branded share surfaceContainerHigh, with a primary icon and an onSurface icon respectively.
  2. Web: hover a FAB - the shadow deepens (level 3 → 4); focus and press stay at level 3. Hover the menu trigger - it does not rise.
  3. Switch Type to menu while surface or branded is selected - the chip set drops both and the variant falls back to primaryContainer.
  4. Toggle the FAB hidden - it can't be focused by Tab, pressing does nothing, and a screen reader skips it. Show it again and hover: elevation starts at rest, not raised.
  5. Size large - the icon measures 36dp, not 32dp.
  6. Screen reader - the icon FAB announces its aria-label; the hidden FAB is not announced.
Screenshot 2026-09-08 at 09 15 29 Screenshot 2026-09-08 at 09 14 49 Screenshot 2026-09-08 at 09 15 11

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.
Comment on lines +164 to +178
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**.

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.

rewrite this to actual migration instructions. don't include implementation details or what it was in a prerelease version.

Comment on lines +73 to +74
surface: { container: 'surfaceContainerHigh', content: 'primary' },
branded: { container: 'surfaceContainerHigh', content: 'onSurface' },

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.

is this actually in the official guidelines?

} as const satisfies Record<string, Elevation>;

const variants = {
surface: { container: 'surfaceContainerHigh', content: 'primary' },

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.

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,

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.

the elevation prop should be removed if elevation is supposed to be dynamic.

Comment on lines +321 to +323
if (Platform.OS === 'web') {
touchableRef.current?.blur();
}

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.

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.

Comment on lines +228 to +236
(Platform.OS === 'web' && visible && onPress
? pressed
? Tokens.stateElevation.pressed
: focused
? Tokens.stateElevation.focus
: hovered
? Tokens.stateElevation.hover
: Tokens.stateElevation.enabled
: Tokens.stateElevation.enabled);

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.

what's the rationale behind different behavior on web and native?

Comment on lines +231 to +234
: focused
? Tokens.stateElevation.focus
: hovered
? Tokens.stateElevation.hover

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.

a hovered element can also be focused. focus shouldn't take priority over hover styling.

Comment on lines +361 to +362
onPress={visible ? onPress : undefined}
disabled={!onPress || !visible}

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.

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

Comment on lines +25 to +29
* 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.

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.

remove

Comment on lines +42 to +46
* 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.

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.

remove

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.

2 participants