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.
iOS (Native UIMenu) |
Android (Modal Dialog) |
- β 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)
npm install react-native-menus
# or
yarn add react-native-menusFor iOS, run:
cd ios && pod installNo additional setup required for Android.
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;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><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><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>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
});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>
);
};| 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 aUIButton's menu through UIKit's own context-menu gesture, andUIControl.performPrimaryActionβ the only public API that triggers it β was added in 17.4. On earlier versionsopen()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.
| 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.
| 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 |
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.
accessibilityLabel wins if you set it. Otherwise the label falls back, in order, to:
- the currently selected item's
accessibilityLabel, then itstitle - the
titleprop - 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>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.
| 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 areMenuItems 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 defaultdialogmode 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.
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.
- 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
accessibilityLabelis applied to the underlyingUIActionand is honoured β an item announces your label instead of the defaulttitle, subtitlepairing (verified on iOS 26). This is undocumented UIKit behaviour rather than a guarantee, so treat it as an enhancement: keeptitlemeaningful on its own.
See the contributing guide to learn how to contribute to the repository and the development workflow.
MIT

