tetra-ui
uimenusource: ok · hit
Menu
A composable native menu built on Expo UI, with support for groups, separators, and nested submenus.
npx shadcn add "https://tetra-ui.com/r/menu.json"free
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@expo/ui@expo/material-symbols
Registry dependencies
@tetra-ui/slots
Files6 · source cached 10/8/2026, 7:43:20 PM
ui/menu/index.ts
export * from "./menu";
ui/menu/menu-types.ts
import type { IconProps as IconPrimitiveProps } from "@expo/ui";
export type MenuItemVariant = "default" | "destructive";
export type MenuProps = {
children: React.ReactNode;
};
export type MenuContentProps = {
children: React.ReactNode;
};
export type MenuTriggerProps = {
children: React.ReactElement;
};
export type MenuGroupProps = {
direction?: "horizontal" | "vertical";
title?: string;
children: React.ReactNode;
};
export type MenuItemProps = {
disabled?: boolean;
variant?: MenuItemVariant;
selected?: boolean;
onPress?: () => void;
children: React.ReactElement | React.ReactElement[];
};
export type MenuItemLabelProps = {
children: string;
};
export type MenuItemIconProps = {
icon: IconPrimitiveProps["name"];
};
ui/menu/menu-slots.ts
import { useLayoutEffect } from "react";
import { createSlots } from "@/lib/slots";
export const menuSlots = createSlots<"content" | "trigger">({
errorMessage: "MenuTrigger and MenuContent must be rendered inside Menu",
});
export const useAssertMenuParts = () => {
const registry = menuSlots.useRegistry();
useLayoutEffect(() => {
if (registry.has("trigger") && registry.has("content")) {
return;
}
throw new Error("Menu must have a MenuTrigger and MenuContent");
}, [registry]);
};
ui/menu/menu.tsx
import type {
MenuContentProps,
MenuGroupProps,
MenuItemIconProps,
MenuItemLabelProps,
MenuItemProps,
MenuProps,
MenuTriggerProps,
} from "./menu-types";
// Components
export const MenuItem = (_: MenuItemProps) => {
return null;
};
export const MenuItemLabel = (_: MenuItemLabelProps) => {
return null;
};
export const MenuItemIcon = (_: MenuItemIconProps) => {
return null;
};
export const MenuSeparator = () => {
return null;
};
export const MenuGroup = (_: MenuGroupProps) => {
return null;
};
export const MenuTrigger = (_: MenuTriggerProps) => {
return null;
};
MenuTrigger.displayName = "MenuTrigger";
export const MenuContent = (_: MenuContentProps) => {
return null;
};
export const Menu = (_: MenuProps) => {
return null;
};
export const MenuSubTrigger = (_: MenuTriggerProps) => {
return null;
};
MenuSubTrigger.displayName = "MenuSubTrigger";
export const MenuSubContent = (_: MenuContentProps) => {
return null;
};
MenuSubContent.displayName = "MenuSubContent";
export const MenuSub = (_: MenuProps) => {
return null;
};
ui/menu/menu.ios.tsx
import {
Host as HostPrimitive,
Icon as IconPrimitive,
Text as TextPrimitive,
} from "@expo/ui";
import {
Button as ButtonPrimitive,
ControlGroup as ControlGroupPrimitive,
Divider as DividerPrimitive,
Menu as MenuPrimitive,
RNHostView,
Section as SectionPrimitive,
Toggle as TogglePrimitive,
} from "@expo/ui/swift-ui";
import {
disabled as disabledModifier,
type ModifierConfig,
} from "@expo/ui/swift-ui/modifiers";
import { Children, isValidElement, useMemo } from "react";
import { menuSlots, useAssertMenuParts } from "./menu-slots";
import type {
MenuContentProps,
MenuGroupProps,
MenuItemIconProps,
MenuItemLabelProps,
MenuItemProps,
MenuProps,
MenuTriggerProps,
} from "./menu-types";
// Types
type MenuItemToggleProps = {
children: React.ReactElement | React.ReactElement[];
selected: boolean;
onPress?: () => void;
modifiers?: ModifierConfig[];
};
// Components
export const MenuItemLabel = ({ children }: MenuItemLabelProps) => {
return <TextPrimitive>{children}</TextPrimitive>;
};
MenuItemLabel.displayName = "MenuItemLabel";
export const MenuItemIcon = ({ icon }: MenuItemIconProps) => {
return <IconPrimitive name={icon} />;
};
MenuItemIcon.displayName = "MenuItemIcon";
const MenuItemToggle = ({
selected,
onPress,
children,
modifiers,
}: MenuItemToggleProps) => {
const selectableParts = useMemo(
() => extractSelectableItemParts(children),
[children]
);
return (
<TogglePrimitive
isOn={selected}
label={selectableParts.label}
modifiers={modifiers}
onIsOnChange={onPress}
systemImage={
typeof selectableParts.systemImage === "string"
? selectableParts.systemImage
: undefined
}
/>
);
};
export const MenuItem = ({
variant = "default",
disabled,
selected,
onPress,
children,
}: MenuItemProps) => {
const modifiers = disabled ? [disabledModifier(true)] : undefined;
if (selected !== undefined) {
return (
<MenuItemToggle
modifiers={modifiers}
onPress={onPress}
selected={selected}
>
{children}
</MenuItemToggle>
);
}
return (
<ButtonPrimitive modifiers={modifiers} onPress={onPress} role={variant}>
{children}
</ButtonPrimitive>
);
};
export const MenuSeparator = DividerPrimitive;
export const MenuGroup = ({
direction = "vertical",
...props
}: MenuGroupProps) => {
if (direction === "horizontal") {
return <ControlGroupPrimitive {...props} label={props.title} />;
}
return <SectionPrimitive {...props} />;
};
export const MenuTrigger = ({ children, ...props }: MenuTriggerProps) => {
return (
<menuSlots.Fill name="trigger">
<RNHostView matchContents {...props}>
{children}
</RNHostView>
</menuSlots.Fill>
);
};
MenuTrigger.displayName = "MenuTrigger";
export const MenuContent = ({ children }: MenuContentProps) => {
return <menuSlots.Fill name="content">{children}</menuSlots.Fill>;
};
MenuContent.displayName = "MenuContent";
export const Menu = (props: MenuProps) => {
return (
<menuSlots.Provider>
<MenuView {...props} />
</menuSlots.Provider>
);
};
const MenuView = ({ children, ...props }: MenuProps) => {
useAssertMenuParts();
return (
<>
{children}
<HostPrimitive matchContents>
<MenuPrimitive {...props} label={<menuSlots.Outlet name="trigger" />}>
<menuSlots.Outlet name="content" />
</MenuPrimitive>
</HostPrimitive>
</>
);
};
export const MenuSubTrigger = ({ children, ...props }: MenuTriggerProps) => {
return (
<menuSlots.Fill name="trigger">
<RNHostView matchContents {...props}>
{children}
</RNHostView>
</menuSlots.Fill>
);
};
MenuSubTrigger.displayName = "MenuSubTrigger";
export const MenuSubContent = ({ children }: MenuContentProps) => {
return <menuSlots.Fill name="content">{children}</menuSlots.Fill>;
};
MenuSubContent.displayName = "MenuSubContent";
export const MenuSub = (props: MenuProps) => {
return (
<menuSlots.Provider>
<MenuSubView {...props} />
</menuSlots.Provider>
);
};
const MenuSubView = ({ children, ...props }: MenuProps) => {
useAssertMenuParts();
return (
<>
{children}
<MenuPrimitive {...props} label={<menuSlots.Outlet name="trigger" />}>
<menuSlots.Outlet name="content" />
</MenuPrimitive>
</>
);
};
// Utils
const extractSelectableItemParts = (
children: React.ReactElement | React.ReactElement[]
) => {
let label: string | undefined;
let systemImage: MenuItemIconProps["icon"] | undefined;
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const displayName = getMenuChildDisplayName(child);
if (displayName === "MenuItemIcon") {
systemImage = (child.props as MenuItemIconProps).icon;
return;
}
if (displayName === "MenuItemLabel") {
label = (child.props as MenuItemLabelProps).children;
}
});
return { label, systemImage };
};
const getMenuChildDisplayName = (child: React.ReactNode) => {
if (!isValidElement(child)) {
return;
}
return (child.type as { displayName?: string }).displayName;
};
ui/menu/menu.android.tsx
import { Icon as IconPrimitive } from "@expo/ui";
import {
Column,
DropdownMenuItem as DropdownMenuItemPrimitive,
DropdownMenu as DropdownMenuPrimitive,
HorizontalDivider as HorizontalDividerPrimitive,
Host as HostPrimitive,
RNHostView,
Row,
Text as TextPrimitive,
} from "@expo/ui/jetpack-compose";
import {
defaultMinSize,
padding,
width,
wrapContentWidth,
} from "@expo/ui/jetpack-compose/modifiers";
import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { Platform, type PressableProps } from "react-native";
import { useCSSVariable } from "uniwind";
import { menuSlots, useAssertMenuParts } from "./menu-slots";
import type {
MenuContentProps,
MenuGroupProps,
MenuItemIconProps,
MenuItemLabelProps,
MenuItemProps,
MenuItemVariant,
MenuProps,
MenuTriggerProps,
} from "./menu-types";
const HORIZONTAL_MENU_ITEM_WIDTH = 48;
const MENU_GROUP_LABEL_MODIFIERS = [padding(12, 8, 12, 4)];
const MENU_GROUP_HORIZONTAL_ROW_MODIFIERS = [
padding(12, 0, 12, 8),
wrapContentWidth(),
];
// Types
type InternalMenuAndroidContextType = {
dismissAll: () => void;
expanded: boolean;
setExpanded: (expanded: boolean) => void;
};
type InternalMenuSubAndroidContextType = {
dismissAll: () => void;
expanded: boolean;
setExpanded: (expanded: boolean) => void;
};
// Context
const MenuAndroidContext = createContext<InternalMenuAndroidContextType | null>(
null
);
const useMenuAndroidContext = () => {
const context = useContext(MenuAndroidContext);
if (!context) {
throw new Error(
"useMenuAndroidContext must be used within a Menu component"
);
}
return context;
};
const MenuSubAndroidContext =
createContext<InternalMenuSubAndroidContextType | null>(null);
const MenuSubTriggerContext = createContext(false);
const MenuGroupHorizontalContext = createContext(false);
const MenuItemAndroidContext = createContext<MenuItemVariant | null>(null);
const useMenuItemAndroidContext = () => {
const context = useContext(MenuItemAndroidContext);
if (!context) {
throw new Error(
"useMenuItemAndroidContext must be used within a MenuItem component"
);
}
return context;
};
// Components
export const MenuItem = ({
variant = "default",
onPress,
children,
disabled,
selected,
...rest
}: MenuItemProps) => {
const { dismissAll: dismissRootMenu } = useMenuAndroidContext();
const subMenu = useContext(MenuSubAndroidContext);
const isSubTrigger = useContext(MenuSubTriggerContext);
const isHorizontalGroup = useContext(MenuGroupHorizontalContext);
const [foregroundColor, destructiveColor] = useCSSVariable([
"--color-foreground",
"--color-destructive",
]) as [string, string];
const modifiers = isHorizontalGroup
? [
defaultMinSize({ minHeight: 0, minWidth: 0 }),
wrapContentWidth(),
width(HORIZONTAL_MENU_ITEM_WIDTH),
]
: undefined;
const elementColors =
variant === "destructive"
? {
disabledLeadingIconColor: destructiveColor,
disabledTextColor: destructiveColor,
leadingIconColor: destructiveColor,
textColor: destructiveColor,
}
: undefined;
const handleClick = useCallback(() => {
if (isSubTrigger && subMenu) {
subMenu.setExpanded(true);
return;
}
onPress?.();
subMenu?.setExpanded(false);
dismissRootMenu();
}, [dismissRootMenu, isSubTrigger, onPress, subMenu]);
return (
<MenuItemAndroidContext.Provider value={variant}>
<DropdownMenuItemPrimitive
{...rest}
elementColors={elementColors}
enabled={!disabled}
modifiers={modifiers}
onClick={handleClick}
>
{children}
{selected ? (
<DropdownMenuItemPrimitive.TrailingIcon>
<TextPrimitive
color={
variant === "destructive" ? destructiveColor : foregroundColor
}
>
✓
</TextPrimitive>
</DropdownMenuItemPrimitive.TrailingIcon>
) : null}
</DropdownMenuItemPrimitive>
</MenuItemAndroidContext.Provider>
);
};
export const MenuItemLabel = ({ children }: MenuItemLabelProps) => {
const variant = useMenuItemAndroidContext();
const [foregroundColor, destructiveColor] = useCSSVariable([
"--color-foreground",
"--color-destructive",
]) as [string, string];
return (
<DropdownMenuItemPrimitive.Text>
<TextPrimitive
color={variant === "destructive" ? destructiveColor : foregroundColor}
>
{children}
</TextPrimitive>
</DropdownMenuItemPrimitive.Text>
);
};
export const MenuItemIcon = ({ icon }: MenuItemIconProps) => {
const variant = useMenuItemAndroidContext();
const [foregroundColor, destructiveColor] = useCSSVariable([
"--color-foreground",
"--color-destructive",
]) as [string, string];
return (
<DropdownMenuItemPrimitive.LeadingIcon>
<IconPrimitive
color={variant === "destructive" ? destructiveColor : foregroundColor}
name={icon}
size={24}
/>
</DropdownMenuItemPrimitive.LeadingIcon>
);
};
export const MenuSeparator = HorizontalDividerPrimitive;
const MenuGroupLabel = ({
children,
color,
}: {
children: string;
color: string;
}) => {
return (
<TextPrimitive color={color} modifiers={MENU_GROUP_LABEL_MODIFIERS}>
{children}
</TextPrimitive>
);
};
export const MenuGroup = ({
direction = "vertical",
title,
children,
}: MenuGroupProps) => {
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
if (direction === "horizontal") {
const row = (
<MenuGroupHorizontalContext.Provider value={true}>
<Row
horizontalArrangement={{ spacedBy: 8 }}
modifiers={MENU_GROUP_HORIZONTAL_ROW_MODIFIERS}
verticalAlignment="center"
>
{children}
</Row>
</MenuGroupHorizontalContext.Provider>
);
if (title) {
return (
<Column modifiers={[wrapContentWidth()]}>
<MenuGroupLabel color={mutedColor}>{title}</MenuGroupLabel>
{row}
</Column>
);
}
return row;
}
if (title) {
return (
<Column>
<MenuGroupLabel color={mutedColor}>{title}</MenuGroupLabel>
{children}
</Column>
);
}
return <>{children}</>;
};
export const MenuTrigger = ({ children, ...props }: MenuTriggerProps) => {
const { setExpanded } = useMenuAndroidContext();
const child = Children.only(children);
if (!child) {
if (__DEV__) {
throw new Error("MenuTrigger expects a single React element as children");
}
return null;
}
return (
<menuSlots.Fill name="trigger">
<DropdownMenuPrimitive.Trigger>
<RNHostView matchContents {...props}>
{cloneElement(child as React.ReactElement<PressableProps>, {
// onPress isn't triggering on Android, so we use onPressIn instead
[Platform.select({
android: "onPressIn",
default: "onPress",
})]: () => setExpanded(true),
})}
</RNHostView>
</DropdownMenuPrimitive.Trigger>
</menuSlots.Fill>
);
};
MenuTrigger.displayName = "MenuTrigger";
export const MenuContent = ({ children }: MenuContentProps) => {
return (
<menuSlots.Fill name="content">
<DropdownMenuPrimitive.Items>{children}</DropdownMenuPrimitive.Items>
</menuSlots.Fill>
);
};
MenuContent.displayName = "MenuContent";
export const Menu = (props: MenuProps) => {
const [expanded, setExpanded] = useState(false);
const popoverColor = useCSSVariable("--color-popover") as string;
const dismissAll = useCallback(() => setExpanded(false), []);
const ctx = useMemo(
() => ({ dismissAll, expanded, setExpanded }),
[dismissAll, expanded]
);
return (
<MenuAndroidContext.Provider value={ctx}>
<menuSlots.Provider>
<MenuView
color={popoverColor}
expanded={expanded}
onDismiss={dismissAll}
>
{props.children}
</MenuView>
</menuSlots.Provider>
</MenuAndroidContext.Provider>
);
};
const MenuView = ({
children,
color,
expanded,
onDismiss,
}: MenuProps & {
color: string;
expanded: boolean;
onDismiss: () => void;
}) => {
useAssertMenuParts();
return (
<>
{children}
<HostPrimitive matchContents>
<DropdownMenuPrimitive
color={color}
expanded={expanded}
onDismissRequest={onDismiss}
>
<menuSlots.Outlet name="trigger" />
<menuSlots.Outlet name="content" />
</DropdownMenuPrimitive>
</HostPrimitive>
</>
);
};
export const MenuSubTrigger = ({ children }: MenuTriggerProps) => {
return (
<menuSlots.Fill name="trigger">
<MenuSubTriggerContext.Provider value={true}>
<DropdownMenuPrimitive.Trigger>
{children}
</DropdownMenuPrimitive.Trigger>
</MenuSubTriggerContext.Provider>
</menuSlots.Fill>
);
};
MenuSubTrigger.displayName = "MenuSubTrigger";
export const MenuSubContent = ({ children }: MenuContentProps) => {
return (
<menuSlots.Fill name="content">
<DropdownMenuPrimitive.Items>{children}</DropdownMenuPrimitive.Items>
</menuSlots.Fill>
);
};
MenuSubContent.displayName = "MenuSubContent";
export const MenuSub = ({ children }: MenuProps) => {
const { dismissAll: dismissRootMenu } = useMenuAndroidContext();
const [expanded, setExpanded] = useState(false);
const dismissAll = useCallback(() => {
setExpanded(false);
dismissRootMenu();
}, [dismissRootMenu]);
const ctx = useMemo(
() => ({ dismissAll, expanded, setExpanded }),
[dismissAll, expanded]
);
return (
<MenuSubAndroidContext.Provider value={ctx}>
<menuSlots.Provider>
<MenuSubView expanded={expanded} onDismiss={() => setExpanded(false)}>
{children}
</MenuSubView>
</menuSlots.Provider>
</MenuSubAndroidContext.Provider>
);
};
const MenuSubView = ({
children,
expanded,
onDismiss,
}: MenuProps & {
expanded: boolean;
onDismiss: () => void;
}) => {
useAssertMenuParts();
return (
<>
{children}
<DropdownMenuPrimitive expanded={expanded} onDismissRequest={onDismiss}>
<menuSlots.Outlet name="trigger" />
<menuSlots.Outlet name="content" />
</DropdownMenuPrimitive>
</>
);
};