shadimage
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>
    </>
  );
};

More from tetra-ui

Browse all components in this library →