Skip to content

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

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

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

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

2 participants