@shaquillehinds/react-native-menu-modal
v0.0.8
Published
A simple location/spot modal for react native that just works.
Maintainers
Readme
@shaquillehinds/react-native-menu-modal
A simple, elegant context menu modal for React Native that appears at the touch location. Perfect for creating contextual menus, dropdown options, and interactive lists triggered by press or long-press gestures.
Features
- 🎯 Position-aware - Opens at exact touch coordinates
- ⚡ Flexible activation - Trigger on press or long-press
- 🎨 Highly customizable - Style every aspect of the menu
- 📱 Cross-platform - Works seamlessly on iOS and Android
- 🔧 TypeScript support - Fully typed for excellent DX
- 🚀 Performant - Built with react-native-reanimated and gesture-handler
- 🎭 Portal support - Render menus in a portal layer or native modal
- ♿ Accessible - Supports subtitles and custom components
- 🎁 Zero config - Works out of the box, no provider required!
Quick Start
import { MenuModal } from '@shaquillehinds/react-native-menu-modal';
// That's it! Works immediately without any setup
<MenuModal
backgroundColor="#1a1a1a"
options={[
{ title: 'Edit', onOptionPress: () => console.log('Edit') },
{ title: 'Delete', onOptionPress: () => console.log('Delete') },
]}
>
<Text>Press me!</Text>
</MenuModal>;Installation
npm install @shaquillehinds/react-native-menu-modalor
yarn add @shaquillehinds/react-native-menu-modalPeer Dependencies
This package requires the following peer dependencies:
npm install @shaquillehinds/react-native-spot-modal react-native-reanimated react-native-gesture-handleror
yarn add @shaquillehinds/react-native-spot-modal react-native-reanimated react-native-gesture-handlerNote: Make sure to complete the setup for react-native-reanimated and react-native-gesture-handler.
Portal Provider Setup (Optional)
The MenuModalPortalProvider is optional. The menu modal works out of the box without it by falling back to React Native's Modal component. However, the portal provider offers better control and rendering flexibility.
Quick Start (No Provider Needed):
import { MenuModal } from '@shaquillehinds/react-native-menu-modal';
// Works immediately - uses React Native Modal as fallback
export default function App() {
return (
<MenuModal
backgroundColor="#1a1a1a"
options={[
{ title: 'Option 1', onOptionPress: () => console.log('1') },
{ title: 'Option 2', onOptionPress: () => console.log('2') },
]}
>
<Text>Press me</Text>
</MenuModal>
);
}With Portal Provider (Recommended for Better Control):
If you want more control over rendering layers, add the provider at the root of your application:
import { MenuModalPortalProvider } from '@shaquillehinds/react-native-menu-modal';
export default function App() {
return (
<MenuModalPortalProvider>{/* Your app content */}</MenuModalPortalProvider>
);
}For Expo Router:
// app/_layout.tsx
import { MenuModalPortalProvider } from '@shaquillehinds/react-native-menu-modal';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<MenuModalPortalProvider>
<Stack />
</MenuModalPortalProvider>
);
}For React Navigation:
import { NavigationContainer } from '@react-navigation/native';
import { MenuModalPortalProvider } from '@shaquillehinds/react-native-menu-modal';
export default function App() {
return (
<MenuModalPortalProvider>
<NavigationContainer>{/* Your navigators */}</NavigationContainer>
</MenuModalPortalProvider>
);
}Note: When using the portal provider, it should be placed as high as possible in your component tree, typically wrapping your navigation or root component.
Usage
Basic Example
import { MenuModal } from '@shaquillehinds/react-native-menu-modal';
import { Text, View } from 'react-native';
export default function App() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<MenuModal
backgroundColor="#1a1a1a"
options={[
{
id: 'edit',
title: 'Edit',
onOptionPress: ({ title }) => console.log(`${title} pressed`),
},
{
id: 'share',
title: 'Share',
onOptionPress: () => console.log('Share pressed'),
},
{
id: 'delete',
title: 'Delete',
subTitle: 'Cannot be undone',
onOptionPress: () => console.log('Delete pressed'),
},
]}
>
<Text>Press me for menu</Text>
</MenuModal>
</View>
);
}Long Press Activation
<MenuModal
backgroundColor="#1a1a1a"
activateOn="long-press"
options={[
{
title: 'Copy',
onOptionPress: () => console.log('Copied!'),
},
{
title: 'Paste',
onOptionPress: () => console.log('Pasted!'),
},
]}
>
<Text>Long press me</Text>
</MenuModal>With Icons and Custom Components
import { Feather } from '@expo/vector-icons';
<MenuModal
backgroundColor="#ffffff"
options={[
{
id: 'download',
title: 'Download',
subTitle: 'Save to device',
leftComponent: (
<Feather
name="download"
size={20}
color="#000"
style={{ marginRight: 12 }}
/>
),
onOptionPress: () => console.log('Download'),
},
{
id: 'star',
title: 'Add to Favorites',
leftComponent: (
<Feather
name="star"
size={20}
color="#FFD700"
style={{ marginRight: 12 }}
/>
),
rightComponent: (
<Text style={{ marginLeft: 'auto', color: '#888' }}>⌘F</Text>
),
onOptionPress: () => console.log('Favorited'),
},
]}
>
<Text>Open Menu</Text>
</MenuModal>;Keeping Menu Open After Selection
<MenuModal
backgroundColor="#2a2a2a"
disableDismissOnPress
options={[
{
title: 'Option 1',
disableDismissOnOptionPress: true,
onOptionPress: ({ dismiss }) => {
console.log('Do something...');
// Manually dismiss when ready
setTimeout(() => dismiss(), 1000);
},
},
{
title: 'Option 2',
onOptionPress: () => console.log('Auto dismisses'),
},
]}
>
<Text>Options</Text>
</MenuModal>Imperative Control with Ref
import { useRef } from 'react';
import type { MenuModalRef } from '@shaquillehinds/react-native-menu-modal';
function MyComponent() {
const menuRef = useRef<MenuModalRef>(null);
const openMenu = () => {
// Open menu at specific coordinates
menuRef.current?.open({ pageX: 100, pageY: 200 });
};
const closeMenu = () => {
menuRef.current?.close();
};
return (
<>
<Button title="Open Menu" onPress={openMenu} />
<MenuModal
refMenuModal={menuRef}
backgroundColor="#1a1a1a"
options={[
{
title: 'Close',
onOptionPress: closeMenu,
},
]}
>
<Text>Child (won't trigger menu, use ref instead)</Text>
</MenuModal>
</>
);
}Scrollable Menu
<MenuModal
backgroundColor="#ffffff"
scrollable
maxHeight={300}
options={Array.from({ length: 20 }, (_, i) => ({
id: `option-${i}`,
title: `Option ${i + 1}`,
subTitle: `Description for option ${i + 1}`,
onOptionPress: ({ title }) => console.log(title),
}))}
>
<Text>Many Options</Text>
</MenuModal>Custom Styling
<MenuModal
backgroundColor="#1e1e1e"
padding={[10, 15]}
borderRadius={20}
separatorStyle={{
backgroundColor: '#444',
height: 2,
marginVertical: 8,
}}
options={[
{
title: 'Custom Styled',
subTitle: 'With custom padding',
padding={[12, 16]},
titleProps: {
fontSize: 'titleL',
customColor: '#ffffff',
},
subTitleProps: {
fontSize: 'bodyS',
customColor: '#999999',
},
onOptionPress: () => console.log('Styled!'),
},
]}
>
<Text>Styled Menu</Text>
</MenuModal>Using Native Modal or Portal
// Use native modal (default behavior via SpotModal)
<MenuModal
backgroundColor="#ffffff"
disableNativeModal={false}
options={[...]}
>
<Text>Native Modal</Text>
</MenuModal>
// Disable portal rendering (renders inline)
<MenuModal
backgroundColor="#ffffff"
disablePortal={true}
options={[...]}
>
<Text>Inline Modal</Text>
</MenuModal>API Reference
MenuModal Props
| Prop | Type | Default | Description |
| ----------------------- | ---------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------- |
| children | ReactNode | required | The component that triggers the menu |
| options | MenuOption[] | required | Array of menu options |
| backgroundColor | string | required | Background color of the menu |
| activateOn | 'press' \| 'long-press' | 'press' | How to trigger the menu |
| onPress | () => void \| Promise<void> | undefined | Callback when trigger is pressed |
| onLongPress | () => void \| Promise<void> | undefined | Callback when trigger is long-pressed |
| separatorStyle | ViewStyle | undefined | Custom style for option separators |
| disableDismissOnPress | boolean | false | Prevent menu from dismissing on option press |
| disablePortal | boolean | false | Disable portal rendering (even if provider exists). Falls back to native modal or inline rendering |
| disableNativeModal | boolean | false | Disable native Modal component. Only use portal (if provider exists) or inline rendering |
| refMenuModal | React.MutableRefObject<MenuModalRef \| null> | undefined | Ref for imperative control |
| scrollable | boolean | false | Enable scrolling for long menus |
| maxHeight | number | undefined | Maximum height when scrollable |
| padding | number \| [number, number] | [2, 5] | Menu padding |
| borderRadius | number | 15 | Menu border radius |
| ...LayoutProps | LayoutProps | - | Additional layout props from @shaquillehinds/react-native-essentials |
MenuOption Interface
| Property | Type | Description |
| ----------------------------- | ------------------------------------------------- | ------------------------------------------------- |
| id | string | Unique identifier for the option |
| title | string | required - Main text of the option |
| subTitle | string | Optional subtitle text |
| leftComponent | ReactNode | Component to render on the left (e.g., icon) |
| rightComponent | ReactNode | Component to render on the right (e.g., shortcut) |
| onOptionPress | (info: MenuOptionInfo) => void \| Promise<void> | Handler for press events |
| onOptionLongPress | (info: MenuOptionInfo) => void \| Promise<void> | Handler for long-press events |
| disableDismissOnOptionPress | boolean | Prevent dismissal when this option is pressed |
| titleProps | MenuOptionTitleTextProps | Custom props for title text |
| subTitleProps | MenuOptionBodyTextProps | Custom props for subtitle text |
| ...LayoutProps | LayoutProps | Additional layout props for the option container |
MenuOptionInfo
The callback parameter passed to onOptionPress and onOptionLongPress:
{
id?: string;
title: string;
subTitle?: string;
index: number;
dismiss: () => void;
}MenuModalRef Methods
| Method | Parameters | Description |
| ------- | ---------------------------------- | -------------------------------------- |
| open | { pageX: number, pageY: number } | Opens the menu at specific coordinates |
| close | - | Closes the menu |
Text Styling Props
MenuOptionTitleTextProps
Extends BaseTextProps from @shaquillehinds/react-native-essentials with:
fontSize?: 'titleS' | 'titleL'
MenuOptionBodyTextProps
Extends BaseTextProps from @shaquillehinds/react-native-essentials with:
fontSize?: 'bodyS' | 'bodyL'
Advanced Usage
Using Portal Hooks
The package exports portal utilities from @shaquillehinds/react-native-spot-modal for advanced use cases:
import {
useMenuModalPortal,
useMenuModalPortalComponent,
type PortalItem,
} from '@shaquillehinds/react-native-menu-modal';
function MyComponent() {
// Access the portal API directly
const portal = useMenuModalPortal();
// Add a component to the portal
const addToPortal = () => {
const portalItem: PortalItem = {
id: 'my-custom-overlay',
component: <MyCustomOverlay />,
};
portal.add(portalItem);
};
// Remove from portal
const removeFromPortal = () => {
portal.remove('my-custom-overlay');
};
return (
<View>
<Button title="Show Overlay" onPress={addToPortal} />
<Button title="Hide Overlay" onPress={removeFromPortal} />
</View>
);
}Using useMenuModalPortalComponent for Reusable Portal Components:
import { useMenuModalPortalComponent } from '@shaquillehinds/react-native-menu-modal';
function MyPortalComponent() {
// Automatically manages adding/removing from portal
useMenuModalPortalComponent({
id: 'notification-toast',
component: (
<View style={styles.toast}>
<Text>This is rendered in the portal!</Text>
</View>
),
});
return null; // This component doesn't render anything directly
}
// Use it anywhere in your app
function App() {
const [showToast, setShowToast] = useState(false);
return (
<MenuModalPortalProvider>
{showToast && <MyPortalComponent />}
<Button title="Show Toast" onPress={() => setShowToast(true)} />
</MenuModalPortalProvider>
);
}Note: These hooks are useful for custom overlay components or when you need more control over portal rendering. For most menu use cases, the standard
MenuModalcomponent handles portal management automatically.
Dynamic Options
const [options, setOptions] = useState([
{ title: 'Option 1', id: '1', onOptionPress: () => console.log('1') },
{ title: 'Option 2', id: '2', onOptionPress: () => console.log('2') },
]);
const addOption = () => {
setOptions((prev) => [
...prev,
{
title: `Option ${prev.length + 1}`,
id: `${prev.length + 1}`,
onOptionPress: () => console.log(prev.length + 1),
},
]);
};
return (
<>
<Button title="Add Option" onPress={addOption} />
<MenuModal backgroundColor="#1a1a1a" options={options}>
<Text>Dynamic Menu</Text>
</MenuModal>
</>
);Async Operations
<MenuModal
backgroundColor="#2a2a2a"
options={[
{
title: 'Save',
onOptionPress: async ({ dismiss }) => {
// Perform async operation
await saveData();
// Dismiss after completion
dismiss();
},
},
]}
>
<Text>Save Menu</Text>
</MenuModal>Conditional Options
const isAdmin = true;
<MenuModal
backgroundColor="#ffffff"
options={[
{
title: 'View',
onOptionPress: () => console.log('View'),
},
...(isAdmin
? [
{
title: 'Edit',
onOptionPress: () => console.log('Edit'),
},
{
title: 'Delete',
onOptionPress: () => console.log('Delete'),
},
]
: []),
]}
>
<Text>Conditional Menu</Text>
</MenuModal>;Nested Information
<MenuModal
backgroundColor="#1a1a1a"
options={[
{
title: 'File Size',
subTitle: '2.4 MB',
leftComponent: <Feather name="file" size={18} color="#888" />,
rightComponent: <Text style={{ color: '#666', fontSize: 12 }}>PDF</Text>,
onOptionPress: () => console.log('File info'),
},
{
title: 'Last Modified',
subTitle: 'December 10, 2024',
leftComponent: <Feather name="clock" size={18} color="#888" />,
onOptionPress: () => console.log('Date info'),
},
]}
>
<Text>Document Info</Text>
</MenuModal>Tips & Best Practices
Performance
- Use
idprop on options for optimal React rendering - Memoize option handlers if they're complex
- Consider using
disableDismissOnPressfor multi-step operations
const handleEdit = useCallback(({ title, dismiss }) => {
console.log(`Editing ${title}`);
dismiss();
}, []);
const options = useMemo(
() => [{ id: 'edit', title: 'Edit', onOptionPress: handleEdit }],
[handleEdit]
);Accessibility
- Always provide descriptive titles
- Use subtitles for additional context
- Consider adding icons for visual clarity
- Test with screen readers
Styling
- Use consistent
backgroundColoracross your app - Match your app's design system with
titlePropsandsubTitleProps - Customize
separatorStyleto match your theme - Consider dark/light mode support
const isDark = useColorScheme() === 'dark';
<MenuModal
backgroundColor={isDark ? '#1a1a1a' : '#ffffff'}
options={options.map((opt) => ({
...opt,
titleProps: {
customColor: isDark ? '#ffffff' : '#000000',
},
}))}
>
<Text>Theme-aware Menu</Text>
</MenuModal>;Rendering Modes & Portal Usage
The menu modal supports multiple rendering modes with automatic fallback:
Default Behavior (Automatic Fallback Chain):
- With Portal Provider → Uses portal layer (best option)
- Without Portal Provider → Falls back to React Native's
Modalcomponent - Both work perfectly fine out of the box!
// Works without any provider - uses React Native Modal
<MenuModal
backgroundColor="#1a1a1a"
options={[...]}
>
<Text>Works Immediately!</Text>
</MenuModal>
// With provider - uses portal layer
<MenuModalPortalProvider>
<MenuModal
backgroundColor="#1a1a1a"
options={[...]}
>
<Text>Uses Portal Layer</Text>
</MenuModal>
</MenuModalPortalProvider>Disabling Native Modal (forces portal or inline):
// If you have a portal provider but want to disable the native modal fallback
<MenuModal
backgroundColor="#1a1a1a"
disableNativeModal={true}
options={[...]}
>
<Text>Portal Only (no native modal fallback)</Text>
</MenuModal>Disabling Portal (forces native modal or inline):
// Even with a portal provider present, opt-out to use native modal
<MenuModalPortalProvider>
<MenuModal
backgroundColor="#1a1a1a"
disablePortal={true}
options={[...]}
>
<Text>Skips Portal, Uses Native Modal</Text>
</MenuModal>
</MenuModalPortalProvider>Disabling Both (inline rendering only):
// Renders within parent container bounds - useful for specific layouts
<MenuModal
backgroundColor="#1a1a1a"
disablePortal={true}
disableNativeModal={true}
options={[...]}
>
<Text>Renders Inline (respects parent overflow/z-index)</Text>
</MenuModal>When to Use Each Mode:
| Mode | Use Case |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Portal (default with provider) | Best for most cases - renders above all content with proper layering |
| Native Modal (default without provider) | Automatic fallback - works everywhere, good for full-screen menus |
| Inline (disablePortal + disableNativeModal) | When you need the menu constrained within a specific container or scrollable area |
| Portal disabled (disablePortal={true}) | Opt out of portal when you have the provider but want native modal behavior for specific menus |
| Native Modal disabled (disableNativeModal={true}) | Force portal-only rendering without native modal fallback |
Example: Mixed Usage
<MenuModalPortalProvider>
{/* Uses portal - rendered in top layer */}
<MenuModal backgroundColor="#1a1a1a" options={normalOptions}>
<Text>Normal Menu</Text>
</MenuModal>
{/* Opts out of portal - uses native modal instead */}
<MenuModal
backgroundColor="#1a1a1a"
disablePortal
options={fullscreenOptions}
>
<Text>Fullscreen Menu</Text>
</MenuModal>
<ScrollView>
{/* Renders inline within ScrollView bounds */}
<MenuModal
backgroundColor="#1a1a1a"
disablePortal
disableNativeModal
options={inlineOptions}
>
<Text>Constrained Menu</Text>
</MenuModal>
</ScrollView>
</MenuModalPortalProvider>Summary: The package is flexible and works immediately without any setup. Add the portal provider for enhanced control, or use the disable props to customize rendering per menu instance.
Troubleshooting
Menu not appearing
- Ensure
backgroundColoris set (it's required) - Check that peer dependencies are installed and properly configured
- Verify react-native-reanimated is properly configured in
babel.config.js:module.exports = { presets: ['module:metro-react-native-babel-preset'], plugins: ['react-native-reanimated/plugin'], // Must be last }; - Check console for any errors
- If using custom
disablePortalanddisableNativeModalprops, ensure the parent container has appropriate dimensions
Portal-specific issues
Note: The portal provider is optional - the menu works fine without it using React Native's Modal component.
If you choose to use the portal provider:
- Place
MenuModalPortalProvideras high as possible in your component tree - If using React Navigation, place provider outside
NavigationContainer - If using Expo Router, place it in
app/_layout.tsx
"Cannot read property 'add' of undefined" error:
- This only occurs if you use portal hooks (
useMenuModalPortal,useMenuModalPortalComponent) without the provider - The
MenuModalcomponent itself does NOT require the provider and will work without this error
Menu position is off
- This is handled by
@shaquillehinds/react-native-spot-modal - Ensure the parent container doesn't have unusual transforms
- Check if any absolute positioning is interfering
TypeScript errors
# Make sure types are properly installed
npm install --save-dev @types/react @types/react-nativeOptions not triggering
- Verify
onOptionPressis defined on the option - Check if
disableDismissOnPressis interfering - Ensure the option isn't disabled via custom props
Related Packages
- @shaquillehinds/react-native-essentials - Core utilities and components
- @shaquillehinds/react-native-spot-modal - Positioning system used internally
Contributing
Contributions are welcome! Please read the contributing guide and code of conduct.
Development Setup
# Clone the repository
git clone https://github.com/shaquillehinds/react-native-menu-modal.git
# Install dependencies
cd react-native-menu-modal
npm install
# Build the package
npm run prepare
# Run tests
npm test
# Lint code
npm run lintLicense
MIT © Shaquille Hinds
Support
Made with ❤️ by Shaquille Hinds
