Skip to content

Latest commit

Β 

History

44 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

React Native Menus

A native menu component for React Native that provides platform-specific context menus for both Android and iOS. Pass any custom component as a child to trigger native menus.

Screenshots

iOS Menu
iOS (Native UIMenu)
Android Menu
Android (Modal Dialog)

Features

  • βœ… Native context menu implementation (UIMenu on iOS, Modal on Android)
  • βœ… Custom trigger components - pass any React Native component as a child
  • βœ… Customizable colors for menu items
  • βœ… Checkmark support with custom colors
  • βœ… SF Symbols support on iOS (iosSymbol property)
  • βœ… Subtitle support for menu items
  • βœ… Destructive action styling (Red text)
  • βœ… Theme variant support (Light/Dark/System)
  • βœ… Scrollable menus for long lists
  • βœ… Event handling for menu item selection
  • βœ… TypeScript support
  • βœ… Fabric (New Architecture) compatible
  • βœ… Improved Accessibility support
  • βœ… Per-item test handles for E2E testing (Detox, Appium, XCUITest)

Installation

npm install react-native-menus
# or
yarn add react-native-menus

iOS Setup

For iOS, run:

cd ios && pod install

Android Setup

No additional setup required for Android.

Usage

Basic Example

import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { MenuView } from 'react-native-menus';

const App = () => {
  const [selectedTheme, setSelectedTheme] = useState('system');

  const handleMenuSelect = (event: {
    nativeEvent: { identifier: string; title: string };
  }) => {
    setSelectedTheme(event.nativeEvent.identifier);
    console.log('Selected:', event.nativeEvent.title);
  };

  return (
    <View style={styles.container}>
      <MenuView
        checkedColor="#007AFF"
        uncheckedColor="#8E8E93"
        menuItems={[
          { identifier: 'light', title: 'Light Mode' },
          { identifier: 'dark', title: 'Dark Mode' },
          { identifier: 'system', title: 'System Default' },
        ]}
        onMenuSelect={handleMenuSelect}
      >
        <View style={styles.menuButton}>
          <Text style={styles.menuButtonText}>
            πŸŒ“ Theme: {selectedTheme}
          </Text>
        </View>
      </MenuView>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  menuButton: {
    backgroundColor: '#fff',
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderRadius: 8,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  menuButtonText: {
    fontSize: 16,
    color: '#333',
  },
});

export default App;

Controlled Selection (Recommended)

Use the selectedIdentifier prop to fully control which item is marked as selected. Update it in your onMenuSelect handler to keep iOS and Android behavior consistent.

const [selectedSort, setSelectedSort] = useState('date');

<MenuView
  selectedIdentifier={selectedSort}
  menuItems={[
    { identifier: 'date', title: 'Date' },
    { identifier: 'name', title: 'Name' },
    { identifier: 'size', title: 'Size' },
  ]}
  onMenuSelect={({ nativeEvent }) => setSelectedSort(nativeEvent.identifier)}
>
  <View style={styles.menuButton}>
    <Text>πŸ“Š Sort by: {selectedSort}</Text>
  </View>
</MenuView>

Custom Styled Trigger

<MenuView
  menuItems={[
    { identifier: 'profile', title: 'View Profile' },
    { identifier: 'settings', title: 'Settings' },
    { identifier: 'logout', title: 'Logout' },
  ]}
  onMenuSelect={handleMenuSelect}
>
  <View style={styles.customButton}>
    <Text style={styles.customButtonText}>πŸ‘€ Account Menu</Text>
  </View>
</MenuView>

Long Scrollable List

<MenuView
  checkedColor="#5856D6"
  menuItems={[
    { identifier: 'opt1', title: 'Option 1' },
    { identifier: 'opt2', title: 'Option 2' },
    // ... many more items
    { identifier: 'opt20', title: 'Option 20' },
  ]}
  onMenuSelect={handleMenuSelect}
>
  <View style={styles.menuButton}>
    <Text>πŸ“‹ Select Option</Text>
  </View>
</MenuView>

Disabled Menu

const [isDisabled, setIsDisabled] = useState(false);

<MenuView
  disabled={isDisabled}
  menuItems={[
    { identifier: 'enable', title: 'Enable Menu' },
    { identifier: 'disable', title: 'Disable Menu' },
  ]}
  onMenuSelect={({ nativeEvent }) => {
    setIsDisabled(nativeEvent.identifier === 'disable');
  }}
>
  <View style={[styles.menuButton, isDisabled && styles.disabledButton]}>
    <Text style={[styles.menuButtonText, isDisabled && styles.disabledText]}>
      {isDisabled ? 'πŸ”’ Menu Disabled' : 'πŸ”“ Menu Enabled'}
    </Text>
  </View>
</MenuView>

// Add these styles
const styles = StyleSheet.create({
  // ... other styles
});

Imperative Usage (Programmatic Open/Close)

You can use a ref to open or close the menu programmatically.

import React, { useRef } from 'react';
import { View, Button } from 'react-native';
import { MenuView, type NativeRef } from 'react-native-menus';

const App = () => {
  const menuRef = useRef<NativeRef>(null);

  return (
    <View>
      <Button 
        title="Open Menu" 
        onPress={() => menuRef.current?.open()} 
      />
      <Button 
        title="Close Menu" 
        onPress={() => menuRef.current?.close()} 
      />

      <MenuView
        ref={menuRef}
        menuItems={[
          { identifier: 'item1', title: 'Item 1' },
          { identifier: 'item2', title: 'Item 2' },
        ]}
        onMenuSelect={(event) => {
          console.log('Selected:', event.nativeEvent.title);
        }}
      >
        <View style={{ width: 100, height: 100, backgroundColor: 'red' }} />
      </MenuView>
    </View>
  );
};

Ref methods

Method Description
open() Opens the menu. No-op while disabled, or if the menu is already open.
close() Dismisses the menu if it is currently open.

iOS requires 17.4 or newer for open(). iOS presents a UIButton's menu through UIKit's own context-menu gesture, and UIControl.performPrimaryAction β€” the only public API that triggers it β€” was added in 17.4. On earlier versions open() logs a warning and does nothing; the menu still opens on tap. close() works on all supported versions.

Note: attaching a ref gives you { open, close } rather than the underlying native view. If you previously relied on ref.current for measure() or findNodeHandle(), wrap the MenuView in a plain View and measure that instead.

API Reference

MenuView Props

Prop Type Default Description
menuItems MenuItem[] [] Array of menu items to display
title string undefined Title of the menu (Android only)
androidDisplayMode 'dialog' | 'tooltip' 'dialog' Display mode for the menu on Android (Android only)
themeVariant 'light' | 'dark' | 'system' 'system' Theme variant for the menu background and text (Android only)
selectedIdentifier string undefined Identifier of the currently selected item
checkedColor string '#007AFF' Color of the checkmark for selected items
uncheckedColor string '#8E8E93' Color of the checkmark for unselected items (Android only)
color string undefined Tint color for the menu button text (if using default button)
disabled boolean false Whether the menu is disabled
onMenuSelect (event: NativeSyntheticEvent<MenuSelectEvent>) => void undefined Callback when a menu item is selected
accessibilityLabel string derived What a screen reader announces for the trigger β€” see Accessibility
menuAccessibilityHint string 'Opens a menu' Announced after the label to explain what activating the trigger does
enforceMinimumTouchTarget boolean true Expands the trigger's hit area to 44pt (iOS) / 48dp (Android). Never changes layout

All standard React Native ViewProps are supported, including testID and accessibilityLabel.

MenuItem Object

Property Type Description
identifier string Unique identifier for the item
title string Text to display
subtitle string Subtitle text (optional)
destructive boolean Whether the item represents a destructive action (red text)
iosSymbol string SF Symbol name (iOS only)
accessibilityLabel string Replaces the item's announced text (defaults to title, then subtitle)
accessibilityHint string Extra guidance announced after the label
testID string Test handle for E2E frameworks β€” defaults to identifier

Accessibility

The trigger is exposed to screen readers as a single button element rather than as loose text, so VoiceOver and TalkBack announce it as actionable, report its disabled state, and (on Android) expose expand/collapse actions.

How the trigger is named

accessibilityLabel wins if you set it. Otherwise the label falls back, in order, to:

  1. the currently selected item's accessibilityLabel, then its title
  2. the title prop
  3. the text content of the children you rendered

So a menu whose trigger shows the current selection is announced correctly with no extra props.

<MenuView
  accessibilityLabel="Advanced options"
  menuAccessibilityHint="Opens advanced actions for this item"
  menuItems={[
    {
      identifier: 'delete',
      title: 'Delete Item',
      destructive: true,
      accessibilityLabel: 'Delete item permanently',
      accessibilityHint: 'This action cannot be undone',
    },
  ]}
  onMenuSelect={handleSelect}
>
  <Text>Advanced</Text>
</MenuView>

Testing menu items

Menu items are drawn by the platform's own menu rather than by React views, so they never appear in the React tree and a testID on MenuView does not reach them. They are still addressable from any E2E framework that drives the UI through the accessibility layer.

Every item exposes a test handle. It defaults to the item's identifier, so existing menus are already addressable without adding anything:

<MenuView
  testID="sort-menu"                                          // the trigger
  menuItems={[
    { identifier: 'delete', title: 'Delete' },                // handle: "delete"
    { identifier: 'share', title: 'Share', testID: 'x' },     // handle: "x"
  ]}
  onMenuSelect={handleSelect}
>
  <Text>Sort</Text>
</MenuView>

Set testID on an item only when you want the test handle to differ from the business identifier you receive in onMenuSelect.

Where it surfaces

Platform Exposed as Matched by
iOS accessibilityIdentifier on the UIAction Detox by.id(), XCUITest .buttons["…"], Appium accessibility id
Android (dialog) viewIdResourceName on the row Detox by.id(), Appium resource-id, UiAutomator

Android uses the same field React Native uses for testID, so test handles stay out of the accessibility label β€” a screen reader still announces the item's text, not its test id.

// Detox β€” open the menu, then tap an item
await element(by.id('sort-menu')).tap();
await element(by.id('delete')).tap();
// Appium
await driver.$('~delete').click();                        // iOS, accessibility id
await driver.$('android=new UiSelector().resourceId("delete")').click();

Not available on Android in androidDisplayMode="tooltip". Those rows are MenuItems rather than views, so there is nowhere to attach a resource id. Match on the item's visible text in that mode, or use the default dialog mode when you need test handles.

On iOS the identifier is set through UIAccessibilityIdentification, which UIMenuElement conforms to, so the API itself is documented. What is not spelled out by Apple is that UIKit then surfaces that identifier on the rendered menu item β€” that part is verified by hand (iOS 26). Keep title meaningful on its own in case a future release changes it.

Target size

enforceMinimumTouchTarget (on by default) grows the trigger's touch and screen-reader activation area to the WCAG 2.2 minimum of 44pt on iOS and 48dp on Android when the rendered view is smaller. It only affects hit-testing β€” nothing moves on screen β€” so it is safe to leave enabled. Android menu rows also carry a 48dp minimum height.

Platform notes

  • Android honours the system "remove animations" setting, moves screen-reader focus into the dialog when it opens, and returns focus to the trigger when it closes.
  • iOS menus are presented by UIKit, which manages its own focus and animations. Per-item accessibilityLabel is applied to the underlying UIAction and is honoured β€” an item announces your label instead of the default title, subtitle pairing (verified on iOS 26). This is undocumented UIKit behaviour rather than a guarantee, so treat it as an enhancement: keep title meaningful on its own.

Contributing

See the contributing guide to learn how to contribute to the repository and the development workflow.

License

MIT

About

A native menu component for React Native that provides platform-specific context menus for both Android and iOS.

Topics

Resources

Code of conduct

Contributing

Stars

24 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages