Skip to main content

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.

PropRenders
labellabelLarge / onSurface, truncated to one line
leadingIcon24dp / onSurfaceVariant
trailingTextlabelLarge / onSurfaceVariant — a keyboard shortcut or short status
trailingIcon24dp / onSurfaceVariant
disabledLabel 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.

PropValuesDefaultMeaning
side'bottom' | 'top''bottom'Which side of the anchor the menu prefers
align'start' | 'center' | 'end''start'Cross-axis alignment against the anchor
offsetnumber0Gap between the anchor edge and the menu, in dp
screenMarginnumber8Minimum distance the menu keeps from every screen edge
maxHeightnumberCap 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

PropTarget
containerColorThe menu surface background
styleThe menu surface
anchorStyleThe View wrapping the anchor
Menu.Item containerColorThe item background; hover/press/focus layers re-derive from it
Menu.Item contentColorThe item's label, both icons, and trailing text
Menu.Item labelStyleThe 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

TokenValue
ContainersurfaceContainer, corner 4dp (cornerExtraSmall), elevation level 2
Widthmin 112dp, max 280dp
Block padding8dp, inside the scroll area
Item height48dp minimum
Item padding12dp horizontal, 12dp between icon, label, and trailing content
Item labellabelLarge / onSurface
Leading / trailing icon24dp / onSurfaceVariant
ScrimNone — 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 report role="menuitem".
  • Items expose accessibilityState.disabled.
  • The anchor is annotated for you, in both the controlled and uncontrolled forms: aria-haspopup="menu" plus an aria-expanded that 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

PropTypeDefaultRequiredDescription
anchorReactNode-YesThe 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.
childrenReactNode-No`Menu.Item`s, `Divider`s, or arbitrary content.
visibleboolean-NoControlled 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-NoCalled when the menu closes — outside press, item press, or Android back. Required to close a controlled menu; optional otherwise.
sideenumbottomNoSide of the anchor the menu prefers. It flips to the other side when it does not fit and that side is roomier.
alignenumstartNoCross-axis alignment against the anchor.
offsetnumber0NoGap between the anchor edge and the menu, in dp.
screenMarginnumber8NoMinimum distance the menu keeps from every screen edge, in dp.
maxHeightnumber-NoCap 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.
containerColorstringsurfaceContainerNoOverride the container (surface) color.
hostNamestring-NoName of the `PortalHost` to render into. Defaults to the root host, which is what puts the menu above every other layer.
styleStyleProp<ViewStyle>-NoStyle applied to the menu surface.
anchorStyleStyleProp<ViewStyle>-NoStyle applied to the `View` wrapping the anchor.
dismissAccessibilityLabelstringClose menuNoScreen-reader label for the full-screen region that closes the menu on an outside press.
testIDstring-NoTest id applied to the menu surface.
onKeyDown(event: { nativeEvent: { key?: string; }; }) => void-No-
PropTypeDefaultRequiredDescription
labelstring-YesItem text.
leadingIconIconSource-NoLeading icon at 24dp. Accepts a string name (resolved via the theme's `iconResolver`), a pre-rendered element, or a render function.
trailingIconIconSource-NoTrailing icon at 24dp. Mutually exclusive with `trailingText` in practice.
trailingTextstring-NoTrailing text — a keyboard shortcut or a short status.
onPress() => void-NoCalled when the item is pressed.
closeOnPressbooleanNoWhether pressing the item closes the menu. Set `false` for an item that toggles something and should stay open.
disabledbooleanNoGreys the item out at 38% and stops it responding.
containerColorstring-NoOverride the item's container background. Hover/press/focus state-layer colors are derived from it automatically.
contentColorstring-NoOverride 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.
labelStyleStyleProp<TextStyle>-NoStyle applied to the label `Text` only — does not affect the icons.
styleStyleProp<ViewStyle>-NoStyle 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.
testIDstring-NoTest id applied to the item container.