Menu
Menus show a list of choices on a temporary surface, anchored to the control that opened them. Follows the Material Design 3 Menu specification.
Menu renders through Portal, so your app must be wrapped in a PortalHost once
at the root. It claims the PORTAL_LAYERS.menu layer, which puts it above bottom sheets,
dialogs, and snackbars — a menu can be opened from any of them.
Usage
Pass the trigger as anchor and the choices as children. With no visible prop, Menu manages
its own open state: it hooks the anchor's press to open, and closes on an outside press, an item
press, or the Android back button.
import { IconButton, Menu, PortalHost } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { Text, View } from 'react-native'
function Screen() {
const [last, setLast] = useState('nothing yet')
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<Text>Last action: {last}</Text>
<Menu
align="end"
anchor={<IconButton icon="dots-vertical" accessibilityLabel="More actions" />}
>
<Menu.Item label="Edit" leadingIcon="pencil-outline" onPress={() => setLast('Edit')} />
<Menu.Item label="Duplicate" leadingIcon="content-copy" onPress={() => setLast('Duplicate')} />
<Menu.Item label="Delete" leadingIcon="trash-can-outline" onPress={() => setLast('Delete')} />
</Menu>
</View>
)
}
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}
The anchor renders where the <Menu> sits in your tree, wrapped in a measuring View. In
self-managing mode it has to be a single element that accepts onPress — every RootNative
pressable does. Its own onPress still fires.
Controlled
Pass visible and onDismiss to drive visibility yourself. Menu then never toggles itself:
the anchor's press is yours to wire, and onDismiss fires for outside presses, item presses,
and Android back.
import { Button, Divider, Menu, PortalHost } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { View } from 'react-native'
const SORTS = ['Name', 'Date modified', 'Size']
function Screen() {
const [open, setOpen] = useState(false)
const [sort, setSort] = useState('Name')
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Menu
visible={open}
onDismiss={() => setOpen(false)}
anchor={
<Button variant="outlined" trailingIcon="chevron-down" onPress={() => setOpen(true)}>
{`Sort: ${sort}`}
</Button>
}
>
{SORTS.map((option) => (
<Menu.Item
key={option}
label={option}
leadingIcon={option === sort ? 'check' : undefined}
closeOnPress={false}
onPress={() => setSort(option)}
/>
))}
<Divider />
<Menu.Item label="Done" onPress={() => setOpen(false)} />
</Menu>
</View>
)
}
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}
Items
Menu.Item renders a leading icon, a label, trailing text, and a trailing icon — all optional
except the label. Anything else you put in a Menu renders as given, which is how a
Divider groups items.
| Prop | Renders |
|---|---|
label | labelLarge / onSurface, truncated to one line |
leadingIcon | 24dp / onSurfaceVariant |
trailingText | labelLarge / onSurfaceVariant — a keyboard shortcut or short status |
trailingIcon | 24dp / onSurfaceVariant |
disabled | Label and both icons dim together at 38% opacity, keeping their own colors; the item stops responding |
An item press closes the menu. Set closeOnPress={false} on an item that toggles something
and should stay open — a sort choice or a "show hidden files" switch.
Placement
side and align are preferences, not commands.
| Prop | Values | Default | Meaning |
|---|---|---|---|
side | 'bottom' | 'top' | 'bottom' | Which side of the anchor the menu prefers |
align | 'start' | 'center' | 'end' | 'start' | Cross-axis alignment against the anchor |
offset | number | 0 | Gap between the anchor edge and the menu, in dp |
screenMargin | number | 8 | Minimum distance the menu keeps from every screen edge |
maxHeight | number | — | Cap the menu's height in dp, scrolling past it |
align is logical: 'start' is the anchor's left edge in LTR and its right edge in RTL.
A menu that would not fit on its preferred side flips to the other side — but only when that side is genuinely roomier, since flipping into an equally cramped side just moves the clipping around. Horizontally, the menu shifts back inside the margin rather than flipping. When neither side has room for the whole menu, it caps at the available height and scrolls.
The menu positions itself from the anchor's window coordinates, measured when it opens. It does not follow the anchor while a scroll view moves underneath it — closing and reopening re-measures.
Height
A menu never exceeds the space available on the side it resolved to — anything past that would put items where they cannot be seen. When the items do not fit, the menu caps at that space and scrolls.
maxHeight caps it sooner. It is a cap, not an override: it only ever makes the menu shorter,
and a value larger than the available space is ignored.
{/* 30 items with room for all of them is a wall of text; 280dp reads better */}
<Menu maxHeight={280} anchor={anchor}>
{options.map((option) => (
<Menu.Item key={option} label={option} onPress={() => select(option)} />
))}
</Menu>
The cap is folded into the placement decision rather than applied after it, so a menu that fits below the anchor once capped stays below it instead of flipping above on account of its uncapped height.
The menu is confined to its PortalHost
A menu can only be seen inside the overlay layer it renders into, so that layer — not the window
— is what it fits itself to. A PortalHost mounted inside a screen, below an app bar or above
a bottom navigation, gives a layer shorter than the window, and a tall menu caps at the layer's
edge instead of reaching past it into a region that would be clipped.
That is why the root of your app is the right place for PortalHost: it gives menus the whole
window to work with. Mount one per screen and menus near the bottom of a long list will have
noticeably less room to open into.
Overrides
| Prop | Target |
|---|---|
containerColor | The menu surface background |
style | The menu surface |
anchorStyle | The View wrapping the anchor |
Menu.Item containerColor | The item background; hover/press/focus layers re-derive from it |
Menu.Item contentColor | The item's label, both icons, and trailing text |
Menu.Item labelStyle | The label Text only — does not affect the icons |
contentColor on an item is how a destructive choice gets error coloring:
<Menu.Item label="Delete" leadingIcon="trash-can-outline" contentColor={theme.colors.error} />
Disabled items always use the MD3 disabled treatment; neither override changes that.
Tokens
| Token | Value |
|---|---|
| Container | surfaceContainer, corner 4dp (cornerExtraSmall), elevation level 2 |
| Width | min 112dp, max 280dp |
| Block padding | 8dp, inside the scroll area |
| Item height | 48dp minimum |
| Item padding | 12dp horizontal, 12dp between icon, label, and trailing content |
| Item label | labelLarge / onSurface |
| Leading / trailing icon | 24dp / onSurfaceVariant |
| Scrim | None — MD3 menus do not dim what is behind them |
Motion
The menu scales in from 80% and fades on springFastSpatial, growing out of the corner nearest
the anchor via transformOrigin. Both collapse to a hard cut under reduced motion — see
Motion.
The surface stays invisible for the frame between mounting and being measured: its own size is what decides which side it lands on, so it has to be laid out before it can be placed.
Accessibility
- The surface reports
role="menu"and items reportrole="menuitem". - Items expose
accessibilityState.disabled. - The anchor is annotated for you, in both the controlled and uncontrolled forms:
aria-haspopup="menu"plus anaria-expandedthat tracks the menu, so a screen reader announces the trigger as opening a menu rather than as a plain button. - On web, focus moves into the menu when it opens, Arrow Down/Up and Tab move between items, Escape dismisses, and focus returns to the anchor on close. See Accessibility. The surface is deliberately not marked modal on native — that would take the dismiss region below out of the accessibility tree along with it.
- The region that catches outside presses is announced as a button labelled "Close menu" — override with
dismissAccessibilityLabel. MD3 menus have no scrim, so this region is transparent; it exists to catch outside presses and to give screen readers a way out. - On web, items show a keyboard focus ring (
secondary, 3dp) driven by the same focus-visible state as the rest of the library.
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
anchor | ReactNode | - | Yes | The trigger. Rendered where the `<Menu>` sits in the tree, wrapped in a measuring `View`. When `visible` is omitted the menu manages its own open state, and it needs to hook the trigger's press: pass a single element that accepts `onPress` (any RootNative pressable does). The element's own `onPress` still fires. |
children | ReactNode | - | No | `Menu.Item`s, `Divider`s, or arbitrary content. |
visible | boolean | - | No | Controlled visibility. Omit to let the menu open itself on an anchor press and close itself on an outside press, an item press, or Android back. |
onDismiss | () => void | - | No | Called when the menu closes — outside press, item press, or Android back. Required to close a controlled menu; optional otherwise. |
side | enum | bottom | No | Side of the anchor the menu prefers. It flips to the other side when it does not fit and that side is roomier. |
align | enum | start | No | Cross-axis alignment against the anchor. |
offset | number | 0 | No | Gap between the anchor edge and the menu, in dp. |
screenMargin | number | 8 | No | Minimum distance the menu keeps from every screen edge, in dp. |
maxHeight | number | - | No | Cap the menu's height in dp, scrolling past it. Only ever makes the menu shorter: the space available on the resolved side still wins, since a menu taller than that would put items where they cannot be seen. Reach for it when a long menu *could* fill the screen but shouldn't — 30 items with room for all of them is a wall of text, and `maxHeight={280}` reads better. |
containerColor | string | surfaceContainer | No | Override the container (surface) color. |
hostName | string | - | No | Name of the `PortalHost` to render into. Defaults to the root host, which is what puts the menu above every other layer. |
style | StyleProp<ViewStyle> | - | No | Style applied to the menu surface. |
anchorStyle | StyleProp<ViewStyle> | - | No | Style applied to the `View` wrapping the anchor. |
dismissAccessibilityLabel | string | Close menu | No | Screen-reader label for the full-screen region that closes the menu on an outside press. |
testID | string | - | No | Test id applied to the menu surface. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |
Menu.Item
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
label | string | - | Yes | Item text. |
leadingIcon | IconSource | - | No | Leading icon at 24dp. Accepts a string name (resolved via the theme's `iconResolver`), a pre-rendered element, or a render function. |
trailingIcon | IconSource | - | No | Trailing icon at 24dp. Mutually exclusive with `trailingText` in practice. |
trailingText | string | - | No | Trailing text — a keyboard shortcut or a short status. |
onPress | () => void | - | No | Called when the item is pressed. |
closeOnPress | boolean | | No | Whether pressing the item closes the menu. Set `false` for an item that toggles something and should stay open. |
disabled | boolean | | No | Greys the item out at 38% and stops it responding. |
containerColor | string | - | No | Override the item's container background. Hover/press/focus state-layer colors are derived from it automatically. |
contentColor | string | - | No | Override every piece of content the item renders — label, both icons, and trailing text — and the state-layer colors derived from it. Defaults are `onSurface` for the label and `onSurfaceVariant` for the rest. |
labelStyle | StyleProp<TextStyle> | - | No | Style applied to the label `Text` only — does not affect the icons. |
style | StyleProp<ViewStyle> | - | No | Style applied to the item container. Static form only — the function form `(state) => style` is not supported because the item drives its background through Reanimated. Use `containerColor` / `contentColor` instead. |
testID | string | - | No | Test id applied to the item container. |