Skip to content

feat(menu): modernize Menu to Material Design 3 specs - #5037

Closed
matkoson wants to merge 10 commits into
callstack:mainfrom
matkoson:feat/modernize-menu
Closed

feat(menu): modernize Menu to Material Design 3 specs#5037
matkoson wants to merge 10 commits into
callstack:mainfrom
matkoson:feat/modernize-menu

Conversation

@matkoson

Copy link
Copy Markdown
Contributor

Motivation

Modernize Menu / Menu.Item to the latest Material Design 3 specs. Reuse theme tokens (shape, typography, motion, color roles) and extract component tokens in the same pattern as Checkbox / FAB / ConnectedButtonGroup.

Spec re-check (M3 menus)

Re-checked M3 menus specs and overview:

  • Container shape corner.large; item first/last/selected corner.medium.
  • Label labelLarge; supporting bodySmall; trailing supporting labelLarge.
  • Default container fill is MD3 surfaceContainerLow. The elevation prop controls shadow only (Paper’s elevation.level2 maps to different surfaceContainer tones, so it is not used as the menu fill).
  • Selected items use tertiaryContainer / onTertiaryContainer.
  • Expressive choices: optional colorScheme="vibrant"; spring-animated item corner morph as focus/selection moves; optional badge and lightweight submenu portal surface; Menu.Section group gaps (8dp) in addition to Divider.

Changes

Tokens / Menu / Menu.Item

  • Container fill surfaceContainerLow; surface corner.large; first/last/selected item corner.medium via layout context (no cloneElement / no displayName filtering)
  • labelLarge, supporting + trailing supporting text, selected colors (disabled wins with content opacity)
  • Spring open/close via toRawSpring + reduce-motion snap
  • colorScheme standard | vibrant
  • Menu.Section group gaps; badge; lightweight submenu portal; Animated.spring per-corner radius morph on focus/selection

Example / docs / tests

  • Example: selection, supporting, vibrant, sections, badge, submenu
  • Docs + theme colors tables for selected/vibrant roles; refreshed Android + iOS screenshots
  • Unit tests for shape, colors, supporting text, dense, motion/reduce-motion, badge, submenu

Related issue

Fixes #4977

Test plan

  • yarn typecheck / yarn lint / Menu unit tests
  • Full unit test suite green
  • Android visual matrix (Pixel 9 Pro XL emulator)
  • iOS visual matrix (iPhone 16 Pro simulator)

Visual verification

Platform closed icons + disabled selected + supporting vibrant bottom anchor
Android (Pixel 9 Pro XL) a-closed a-icons a-sel a-vib a-bot
iOS (iPhone 16 Pro) i-closed i-icons i-sel i-vib i-bot

Docs screenshots: docs/public/screenshots/menu-*.png and menu-ios-*.png.

@MikitasK MikitasK left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

great work on this modernization 👏
just a few suggestions to consider before merging the PR:

const { titleColor, iconColor, contentOpacity } = getMenuItemColor({
const layout = useMenuItemLayout();

const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? 'standard';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what about using MenuRootContext as a fallback here?
I suppose, items rendered through wrapper / Fragment won't receive MenuItemLayoutContext & will fall back to standard scheme even inside vibrant menu

Suggested change
const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? 'standard';
const root = useMenuRoot();
const colorScheme = colorSchemeProp ?? layout?.colorScheme ?? root?.colorScheme ?? 'standard';

>
{title}
</Text>
{supportingText ? (

@MikitasK MikitasK Aug 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could use explicit presence check for supportingText & trailingSupportingText?
this way values like supportingText={0} will be rendered

instead, we can define such variable:

 const hasSupportingText = supportingText != null && typeof supportingText !== 'boolean';

and then use it here

Suggested change
{supportingText ? (
{hasSupportingText ? (

</Text>
) : null}
</View>
{trailingSupportingText ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Comment on lines +292 to +294
...(supportingText
? { paddingVertical: 8 }
: { height: dense ? denseItemHeight : itemHeight }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we allow the item to grow when trailing supporting text is present?
it currently keeps fixed 48/32dp height & might be clipped with large accessibility font sizes

we can define the following vars first:

const hasSupportingText = supportingText != null && typeof supportingText !== 'boolean';
const hasTrailingSupportingText = trailingSupportingText != null && typeof trailingSupportingText !== 'boolean';

and then use it like that:

Suggested change
...(supportingText
? { paddingVertical: 8 }
: { height: dense ? denseItemHeight : itemHeight }),
...(hasSupportingText || hasTrailingSupportingText
? { paddingVertical: 8 }
: { height: dense ? denseItemHeight : itemHeight }),

Comment on lines +252 to 257
const contentMaxWidth = getContentMaxWidth({
iconWidth: iconSize,
leadingIcon,
trailingIcon,
hasTrailingSupportingText: Boolean(trailingSupportingText),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

could we use explicit presence flags for these props?
this approach should keep numeric values (such as 0), but ignore boolean placeholders

Suggested change
const contentMaxWidth = getContentMaxWidth({
iconWidth: iconSize,
leadingIcon,
trailingIcon,
hasTrailingSupportingText: Boolean(trailingSupportingText),
});
const hasTrailingSupportingText = trailingSupportingText != null && typeof trailingSupportingText !== 'boolean';
const contentMaxWidth = getContentMaxWidth({
iconWidth: iconSize,
leadingIcon,
trailingIcon,
hasTrailingSupportingText,
});

@mikolajadamowicz

Copy link
Copy Markdown

Hey @MikitasK i've took over the PR and fixed your comments here: #5073

@@ -0,0 +1,86 @@
import { Animated } from 'react-native';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please use Reanimated

*/
export function countMenuItems(children: React.ReactNode): number {
let count = 0;
React.Children.forEach(children, (child) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Drop the React.Children walk — it's one of the three things v6 exists to remove (#4954). #5018 and #5076 both replace the same pattern with context plus typed props.


const shapes = {
/** Menu surface corner. Spec: corner.large (16dp). */
container: 'large' as ShapeToken,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try not to use as casts

opacityAnimation: opacityAnimationRef.current,
menuWidth: menuLayoutResult.width,
menuHeight: menuLayoutResult.height,
theme: theme as Theme,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try not to use as casts

row: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

alignSelf: 'flex-start' shrink-wraps the row, so the trailing shortcut sits next to the label instead of at the item's trailing edge — visible in menu-2.png. Drop it and give content flexGrow: 1.

aria-disabled={disabled}
aria-checked={ariaChecked}
aria-selected={ariaSelected}
aria-selected={ariaSelected ?? (isSelected ? true : undefined)}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

aria-selected isn't valid on role="menuitem", so on web a selected item announces the same as an unselected one. Use menuitemradio / menuitemcheckbox with aria-checked.

Comment thread jest/setNodeEnv.js
// Must run before any React Native modules load. RN Animated only force-updates
// host styles under Jest when NODE_ENV === 'test'; otherwise setValue/timing
// go through setNativeProps, which does not update props visible to toHaveStyle.
process.env.NODE_ENV = 'test';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Jest already sets NODE_ENV=test when it's unset (jest-cli/bin/jest.js) and nothing in this repo overrides it, so this file is a no-op. Remove it and the setupFiles entry.

## Theme colors

<ThemeColorsTable themeColorsData={{"-":{"backgroundColor":"theme.colors.elevation.level2"}}} componentName="Menu" />
<ThemeColorsTable themeColorsData={{"standard":{"backgroundColor":"theme.colors.surfaceContainerLow"},"vibrant":{"backgroundColor":"theme.colors.tertiaryContainer"},"selected item":{"backgroundColor":"theme.colors.tertiaryContainer","textColor":"theme.colors.onTertiaryContainer"}}} componentName="Menu" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This page is generated with componentName="Menu", so FAB.Menu now claims a surfaceContainerLow fill and a vibrant tertiaryContainer variant it doesn't have, and shows Menu's screenshots.

@mikolajadamowicz

Copy link
Copy Markdown

@JKobrynski i have updated my PR #5073 to fix bugs from your comments. I think we can close this one and continue review on #5073 -> it preserves the git history of this PR, is rebased onto the newest main and has commits with comments fixes

@JKobrynski

Copy link
Copy Markdown
Collaborator

@mikolajadamowicz closing this one

@JKobrynski JKobrynski closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: modernize Menu to the latest Material Design specs

4 participants