Repository: expo/expo
Pull request #35369: Add context menu previews and change API
Author: login: aleqsio
Labels: name: bot: suggestions
description: ExpoBot has some suggestions
Created: 2025-03-07T18:13:24Z | Merged: 2025-03-14T12:39:52Z

# Why

Allows us to show previews.
<img width="348" alt="image" src="https://github.com/user-attachments/assets/f7071cd1-66a8-44c9-815e-333e8a896bbe" />

Changes API like so:
```
<ContextMenu>
  <ContextMenu.Items>
  </ContextMenu.Items>
  <ContextMenu.Trigger>
  </ContextMenu.Trigger>
  <ContextMenu.Preview>
  </ContextMenu.Preview>
</ContextMenu>
```
# How

Inspired by @nandorojo 

# Test Plan

Works on iOS and Android in simulator.

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)



Diff:
diff --git a/apps/native-component-list/src/screens/UI/ContextMenuScreen.tsx b/apps/native-component-list/src/screens/UI/ContextMenuScreen.tsx
index bc711f49df9cdb..ea5934694404d1 100644
--- a/apps/native-component-list/src/screens/UI/ContextMenuScreen.tsx
+++ b/apps/native-component-list/src/screens/UI/ContextMenuScreen.tsx
@@ -4,7 +4,7 @@ import { Picker } from '@expo/ui/components/Picker';
 import { Switch } from '@expo/ui/components/Switch';
 import { useVideoPlayer, VideoView } from 'expo-video';
 import * as React from 'react';
-import { View, StyleSheet, Platform } from 'react-native';
+import { View, StyleSheet, Platform, Text } from 'react-native';
 
 import { Section } from '../../components/Page';
 
@@ -21,111 +21,119 @@ export default function ContextMenuScreen() {
     player.muted = true;
     player.play();
   });
-  const MenuItems = (
-    <>
-      <Button
-        systemImage={{ ios: 'person.crop.circle.badge.xmark' }}
-        onPress={() => console.log('Pressed1')}>
-        Hello
-      </Button>
-      <Button
-        variant="bordered"
-        systemImage={{ ios: 'heart' }}
-        onPress={() => console.log('Pressed2')}>
-        I love
-      </Button>
-      <Picker
-        label="Doggos"
-        options={['very', 'veery', 'veeery', 'much']}
-        variant="menu"
-        selectedIndex={selectedIndex}
-        onOptionSelected={({ nativeEvent: { index } }) => setSelectedIndex(index)}
-      />
-      <Switch
-        value={switchChecked}
-        label="Do u love doggos?"
-        variant="checkbox"
-        onValueChange={setSwitchChecked}
-      />
-      <Switch
-        value={switch2Checked}
-        variant="switch"
-        label="Will u marry doggos?"
-        onValueChange={setSwitch2Checked}
-      />
-      <Button role="destructive" systemImage={{ ios: 'hand.thumbsdown' }}>
-        I don't like doggos 😡
-      </Button>
-      <Submenu button={<Button systemImage={{ ios: 'heart.slash' }}>Evil submenu</Button>}>
-        <Button>I hate</Button>
-        <Button>doggos</Button>
-        <Submenu button={<Button>👹Very evil submenu 👺</Button>}>
-          <Button>I KILL</Button>
-          <Button>DOGGOS</Button>
-        </Submenu>
-      </Submenu>
-    </>
-  );
 
   return (
     <View>
       <Section title="Single-Press Context Menu" row>
-        <ContextMenu Items={MenuItems} style={{ width: 150, height: 50 }}>
-          <Button variant="bordered" style={{ width: 150, height: 50 }}>
-            Show Menu
-          </Button>
+        <ContextMenu style={{ width: 150, height: 50 }}>
+          <ContextMenu.Items>
+            <Button
+              systemImage={{ ios: 'person.crop.circle.badge.xmark' }}
+              onPress={() => console.log('Pressed1')}>
+              Hello
+            </Button>
+            <Button
+              variant="bordered"
+              systemImage={{ ios: 'heart' }}
+              onPress={() => console.log('Pressed2')}>
+              I love
+            </Button>
+            <Picker
+              label="Doggos"
+              options={['very', 'veery', 'veeery', 'much']}
+              variant="menu"
+              selectedIndex={selectedIndex}
+              onOptionSelected={({ nativeEvent: { index } }) => setSelectedIndex(index)}
+            />
+          </ContextMenu.Items>
+          <ContextMenu.Trigger>
+            <Button variant="bordered" style={{ width: 150, height: 50 }}>
+              Show Menu
+            </Button>
+          </ContextMenu.Trigger>
         </ContextMenu>
       </Section>
       <Section title="Long-Press Context Menu" row>
-        <ContextMenu activationMethod="longPress" style={styles.longPressMenu} Items={MenuItems}>
-          <View style={styles.longPressMenu}>
-            <VideoView player={player} style={styles.longPressMenu} contentFit="cover" />
-          </View>
+        <ContextMenu activationMethod="longPress" style={styles.longPressMenu}>
+          <ContextMenu.Items>
+            <Switch
+              value={switchChecked}
+              label="Do u love doggos?"
+              variant="checkbox"
+              onValueChange={setSwitchChecked}
+            />
+            <Switch
+              value={switch2Checked}
+              variant="switch"
+              label="Will u marry doggos?"
+              onValueChange={setSwitch2Checked}
+            />
+            <Button role="destructive" systemImage={{ ios: 'hand.thumbsdown' }}>
+              I don't like doggos 😡
+            </Button>
+            <Submenu button={<Button systemImage={{ ios: 'heart.slash' }}>Evil submenu</Button>}>
+              <Button>I hate</Button>
+              <Button>doggos</Button>
+              <Submenu button={<Button>👹Very evil submenu 👺</Button>}>
+                <Button>I KILL</Button>
+                <Button>DOGGOS</Button>
+              </Submenu>
+            </Submenu>
+          </ContextMenu.Items>
+          <ContextMenu.Trigger>
+            <View style={styles.longPressMenu}>
+              <VideoView player={player} style={styles.longPressMenu} contentFit="cover" />
+            </View>
+          </ContextMenu.Trigger>
+          <ContextMenu.Preview>
+            <View style={styles.preview}>
+              <Text>This is a preview</Text>
+            </View>
+          </ContextMenu.Preview>
         </ContextMenu>
       </Section>
       {Platform.OS === 'android' && (
         <Section title="Colorful Context Menu">
-          <ContextMenu
-            color="#e3b7ff"
-            Items={
-              <>
-                <Button variant="bordered" color="#ff0000">
-                  I'm red!
-                </Button>
-                <Button
-                  variant="bordered"
-                  elementColors={{ containerColor: '#0000ff', contentColor: '#00ff00' }}>
-                  My text is green!
-                </Button>
-                <Switch
-                  value={switchChecked}
-                  label="I'm very colorful!"
-                  variant="checkbox"
-                  elementColors={{
-                    checkedColor: '#ff0000',
-                    disabledCheckedColor: '#00ff00',
-                    uncheckedColor: '#0000ff',
-                    checkmarkColor: '#ffff00',
-                  }}
-                  onValueChange={setSwitchChecked}
-                />
-                <Switch
-                  value={switch2Checked}
-                  variant="switch"
-                  label="Switches can be colorul too!"
-                  onValueChange={setSwitch2Checked}
-                  elementColors={{
-                    checkedThumbColor: '#ff0000',
-                    checkedTrackColor: '#00ff00',
-                    uncheckedThumbColor: '#0000ff',
-                    uncheckedTrackColor: '#ffff00',
-                  }}
-                />
-              </>
-            }>
-            <Button variant="bordered" style={{ width: 200, height: 50 }}>
-              Show Colorful Menu
-            </Button>
+          <ContextMenu color="#e3b7ff">
+            <ContextMenu.Trigger>
+              <Button variant="bordered" style={{ width: 200, height: 50 }}>
+                Show Colorful Menu
+              </Button>
+            </ContextMenu.Trigger>
+            <ContextMenu.Items>
+              <Button variant="bordered" color="#ff0000">
+                I'm red!
+              </Button>
+              <Button
+                variant="bordered"
+                elementColors={{ containerColor: '#0000ff', contentColor: '#00ff00' }}>
+                My text is green!
+              </Button>
+              <Switch
+                value={switchChecked}
+                label="I'm very colorful!"
+                variant="checkbox"
+                elementColors={{
+                  checkedColor: '#ff0000',
+                  disabledCheckedColor: '#00ff00',
+                  uncheckedColor: '#0000ff',
+                  checkmarkColor: '#ffff00',
+                }}
+                onValueChange={setSwitchChecked}
+              />
+              <Switch
+                value={switch2Checked}
+                variant="switch"
+                label="Switches can be colorul too!"
+                onValueChange={setSwitch2Checked}
+                elementColors={{
+                  checkedThumbColor: '#ff0000',
+                  checkedTrackColor: '#00ff00',
+                  uncheckedThumbColor: '#0000ff',
+                  uncheckedTrackColor: '#ffff00',
+                }}
+              />
+            </ContextMenu.Items>
           </ContextMenu>
         </Section>
       )}
@@ -146,4 +154,10 @@ const styles = StyleSheet.create({
     width: 200,
     height: 200,
   },
+  preview: {
+    width: 300,
+    height: 200,
+    padding: 20,
+    backgroundColor: '#ffeeee',
+  },
 });
diff --git a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewDefinition.swift b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewDefinition.swift
index f0c4f26b468727..6431149b0a1c45 100644
--- a/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewDefinition.swift
+++ b/packages/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIViewDefinition.swift
@@ -29,7 +29,7 @@ public extension ExpoSwiftUIView {
    Returns React's children as SwiftUI views, with any nested HostingViews stripped out.
    */
   func UnwrappedChildren<T: View>( // swiftlint:disable:this identifier_name
-    children: [ExpoSwiftUI.Child]? = nil,
+    children: [ExpoSwiftUI.Child?]? = nil,
     @ViewBuilder transform: @escaping (_ child: AnyView, _ isHostingView: Bool)
     -> T = { child, _ in  child }
   ) -> some View {
@@ -39,17 +39,18 @@ public extension ExpoSwiftUIView {
     let childrenArray = Array(children)
     return AnyView(
       ForEach(0..<childrenArray.count, id: \.self) { index in
-        let child = childrenArray[index]
-        if let hostingView = child.view as? (any ExpoSwiftUI.AnyHostingView) {
-          let content = hostingView.getContentView()
-          let propsObject = hostingView.getProps() as any ObservableObject
-          transform(
-            AnyView(
-              content
-                .environmentObject(propsObject)
-                .environmentObject(ExpoSwiftUI.ShadowNodeProxy.SHADOW_NODE_MOCK_PROXY)), true)
-        } else {
-          transform(AnyView(child), false)
+        if let child = childrenArray[index] {
+          if let hostingView = child.view as? (any ExpoSwiftUI.AnyHostingView) {
+            let content = hostingView.getContentView()
+            let propsObject = hostingView.getProps() as any ObservableObject
+            transform(
+              AnyView(
+                content
+                  .environmentObject(propsObject)
+                  .environmentObject(ExpoSwiftUI.ShadowNodeProxy.SHADOW_NODE_MOCK_PROXY)), true)
+          } else {
+            transform(AnyView(child), false)
+          }
         }
       }
     )
diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md
index bdf314d0706ede..5cc78580c795e9 100644
--- a/packages/expo-ui/CHANGELOG.md
+++ b/packages/expo-ui/CHANGELOG.md
@@ -8,6 +8,7 @@
 
 ### 🎉 New features
 
+- Add context menu previews. ([#35369](https://github.com/expo/expo/pull/35369) by [@aleqsio](https://github.com/aleqsio)).
 - Add TextInput for iOS. ([#35098](https://github.com/expo/expo/pull/35098) by [@aleqsio](https://github.com/aleqsio))
 - Add `disabled` button prop. ([#35115](https://github.com/expo/expo/pull/35115) by [@andrew-levy](https://github.com/andrew-levy))
 - Add Radio option to Picker Component for Android. ([#34629](https://github.com/expo/expo/pull/34629)) by [@benjaminkomen](https://github.com/benjaminkomen)
diff --git a/packages/expo-ui/build/components/ContextMenu/index.android.d.ts b/packages/expo-ui/build/components/ContextMenu/index.android.d.ts
new file mode 100644
index 00000000000000..163523903ee732
--- /dev/null
+++ b/packages/expo-ui/build/components/ContextMenu/index.android.d.ts
@@ -0,0 +1,23 @@
+import { ContextMenuProps } from '.';
+export declare function Submenu(): import("react").JSX.Element;
+export declare function Items(): import("react").JSX.Element;
+export declare namespace Items {
+    var tag: string;
+}
+export declare function Trigger(props: {
+    children: React.ReactNode;
+}): import("react").JSX.Element;
+export declare namespace Trigger {
+    var tag: string;
+}
+export declare function Preview(props: {
+    children: React.ReactNode;
+}): import("react").JSX.Element;
+declare function ContextMenu(props: ContextMenuProps): import("react").JSX.Element;
+declare namespace ContextMenu {
+    var Trigger: typeof import("./index.android").Trigger;
+    var Preview: typeof import("./index.android").Preview;
+    var Items: typeof import("./index.android").Items;
+}
+export { ContextMenu };
+//# sourceMappingURL=index.android.d.ts.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/components/ContextMenu/index.android.d.ts.map b/packages/expo-ui/build/components/ContextMenu/index.android.d.ts.map
new file mode 100644
index 00000000000000..a5f54a0056fdbe
--- /dev/null
+++ b/packages/expo-ui/build/components/ContextMenu/index.android.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.android.d.ts","sourceRoot":"","sources":["../../../components/ContextMenu/index.android.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,gBAAgB,EAAkC,MAAM,GAAG,CAAC;AAQrE,wBAAgB,OAAO,gCAEtB;AAED,wBAAgB,KAAK,gCAEpB;yBAFe,KAAK;;;AAKrB,wBAAgB,OAAO,CAAC,KAAK,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,+BAE3D;yBAFe,OAAO;;;AAKvB,wBAAgB,OAAO,CAAC,KAAK,EAAE;IAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAAE,+BAE3D;AAED,iBAAS,WAAW,CAAC,KAAK,EAAE,gBAAgB,+BAmC3C;kBAnCQ,WAAW;;;;;AAyCpB,OAAO,EAAE,WAAW,EAAE,CAAC"}
\ No newline at end of file
diff --git a/packages/expo-ui/build/components/ContextMenu/index.d.ts b/packages/expo-ui/build/components/ContextMenu/index.d.ts
index 8e8e5170d61908..255d906ed4b889 100644
--- a/packages/expo-ui/build/components/ContextMenu/index.d.ts
+++ b/packages/expo-ui/build/components/ContextMenu/index.d.ts
@@ -1,5 +1,6 @@
 import { ReactElement, ReactNode } from 'react';
 import { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
+import { MenuElement } from './utils';
 import { ButtonProps } from '../Button';
 import { PickerProps } from '../Picker';
 import { SwitchProps } from '../Switch';
@@ -35,12 +36,6 @@ export type ActivationMethod = 'singlePress' | 'longPress';
  * Props of the `ContextMenu` component.
  */
 export type ContextMenuProps = {
-    /**
-     * Items visible inside the context menu. The items should be wrapped in a `React.Fragment`.
-     * `Button`, `Switch` and `Submenu` components are supported on both Android and iOS.
-     * The `Picker` component is supported only on iOS. Remember to use components from the `@expo/ui` library.
-     */
-    Items: React.ReactElement<ContextMenuContentProps>;
     /**
      * Determines how the context menu will be activated.
      *
@@ -76,11 +71,53 @@ export type SubmenuProps = {
      */
     children: React.ReactNode;
 };
+/**
+ * @hidden
+ */
+export type NativeMenuProps = ContextMenuProps & {
+    elements: MenuElement[];
+    onContextMenuButtonPressed: (event: NativeSyntheticEvent<{
+        contextMenuElementID: string;
+    }>) => void;
+    onContextMenuSwitchValueChanged: (event: NativeSyntheticEvent<{
+        contextMenuElementID: string;
+        value: boolean;
+    }>) => void;
+    onContextMenuPickerOptionSelected: (event: NativeSyntheticEvent<{
+        index: number;
+        label: string;
+        contextMenuElementID: string;
+    }>) => void;
+};
 /**
  * The `Submenu` component is used to create a nested context menu. Submenus can be infinitely nested.
  * Android does not support nesting in the context menu. All the submenus will be flat-mapped into a single level with multiple titled sections.
  */
 export declare function Submenu(props: SubmenuProps): import("react").JSX.Element;
+/**
+ * Items visible inside the context menu. Pass input components as immidiate children of the tag.
+ * `Button`, `Switch` and `Submenu` components are supported on both Android and iOS.
+ * The `Picker` component is supported only on iOS. Remember to use components from the `@expo/ui` library.
+ */
+export declare function Items(props: {
+    children: React.ReactNode;
+}): import("react").JSX.Element;
+export declare namespace Items {
+    var tag: string;
+}
+/**
+ * The component visible all the time that triggers the menu when tapped or long-pressed.
+ */
+export declare function Trigger(props: {
+    children: React.ReactNode;
+}): import("react").JSX.Element;
+/**
+ * The component visible above the menu when it is opened.
+ * @platform ios
+ */
+export declare function Preview(props: {
+    children: React.ReactNode;
+}): import("react").JSX.Element;
 /**
  * `ContextMenu` allows you to create a context menu, which can be used to provide additional options to the user.
  *
@@ -90,6 +127,11 @@ export declare function Submenu(props: SubmenuProps): import("react").JSX.Elemen
  * - Android does not support nesting in the context menu. All the submenus will be flat-mapped into a single level with multiple sections. The `title` prop of the `Button`, which opens the submenu on iOS will be used as a section title.
  * - Android does not support showing a `Picker` element in the context menu.
  */
-export declare function ContextMenu(props: ContextMenuProps): import("react").JSX.Element;
-export {};
+declare function ContextMenu(props: ContextMenuProps): import("react").JSX.Element;
+declare namespace ContextMenu {
+    var Trigger: typeof import(".").Trigger;
+    var Preview: typeof import(".").Preview;
+    var Items: typeof import(".").Items;
+}
+export { ContextMenu };
 //# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/components/ContextMenu/index.d.ts.map b/packages/expo-ui/build/components/ContextMenu/index.d.ts.map
index e6df282e44f1e4..f1383f2166a0f0 100644
--- a/packages/expo-ui/build/components/ContextMenu/index.d.ts.map
+++ b/packages/expo-ui/build/components/ContextMenu/index.d.ts.map
@@ -1 +1 @@
-{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../components/ContextMenu/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAW,MAAM,OAAO,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE1E,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAGxC,KAAK,cAAc,GACf,YAAY,CAAC,WAAW,CAAC,GACzB,YAAY,CAAC,WAAW,CAAC,GACzB,YAAY,CAAC,WAAW,CAAC,GACzB,YAAY,CAAC,YAAY,CAAC,CAAC;AAE/B,KAAK,eAAe,GAAG,cAAc,GAAG,cAAc,EAAE,CAAC;AAEzD;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,EAAE,eAAe,CAAC;CAC3B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,CAAC,GAAG,EAAE,MAAM,GAAG;QACb,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,KAAK,EAAE,oBAAoB,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC;KAC3D,CAAC;CACH,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,sBAAsB,GAAG;IAAE,oBAAoB,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtE;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,WAAW,CAAC;AAE3D;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;;OAIG;IACH,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,uBAAuB,CAAC,CAAC;IAEnD;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB;;OAEG;IACH,MAAM,EAAE,KAAK,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC;IACxC;;OAEG;IACH,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC;AA2BF;;;GAGG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,+BAE1C;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,+BAwBlD"}
\ No newline at end of file
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../components/ContextMenu/index.tsx"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/packages/expo-ui/components/ContextMenu/index.android.tsx b/packages/expo-ui/components/ContextMenu/index.android.tsx
new file mode 100644
index 00000000000000..149e4842f8aa44
--- /dev/null
+++ b/packages/expo-ui/components/ContextMenu/index.android.tsx
@@ -0,0 +1,72 @@
+import { requireNativeView } from 'expo';
+import { Children, useMemo } from 'react';
+import { NativeSyntheticEvent } from 'react-native';
+
+import { ContextMenuProps, EventHandlers, NativeMenuProps } from '.';
+import { transformChildrenToElementArray } from './utils';
+
+const MenuNativeView: React.ComponentType<NativeMenuProps> = requireNativeView(
+  'ExpoUI',
+  'ContextMenu'
+);
+
+export function Submenu() {
+  return <></>;
+}
+
+export function Items() {
+  return <></>;
+}
+Items.tag = 'Items';
+
+export function Trigger(props: { children: React.ReactNode }) {
+  return <></>;
+}
+Trigger.tag = 'Trigger';
+
+export function Preview(props: { children: React.ReactNode }) {
+  return <></>;
+}
+
+function ContextMenu(props: ContextMenuProps) {
+  const eventHandlersMap: EventHandlers = {};
+  const initialChildren = Children.map(
+    props.children as any,
+    (c: { type: { tag: string }; props: { children: React.ReactNode } }) =>
+      c.type.tag === Items.tag ? c.props.children : null
+  );
+  const processedElements = useMemo(
+    () => transformChildrenToElementArray(initialChildren, eventHandlersMap),
+    [initialChildren]
+  );
+
+  const activationElement = Children.map(
+    props.children as any,
+    (c: { type: { tag: string }; props: { children: React.ReactNode } }) =>
+      c.type.tag === Trigger.tag ? c.props.children : null
+  );
+
+  const createEventHandler =
+    (handlerType: string) => (e: NativeSyntheticEvent<{ contextMenuElementID: string }>) => {
+      const handler = eventHandlersMap[e.nativeEvent.contextMenuElementID]?.[handlerType];
+      handler?.(e);
+    };
+
+  return (
+    <MenuNativeView
+      style={props.style}
+      elements={processedElements}
+      onContextMenuButtonPressed={createEventHandler('onPress')}
+      onContextMenuSwitchValueChanged={createEventHandler('onValueChange')}
+      onContextMenuPickerOptionSelected={createEventHandler('onOptionSelected')}
+      {...props}>
+      {activationElement}
+    </MenuNativeView>
+  );
+}
+
+ContextMenu.Trigger = Trigger;
+ContextMenu.Preview = Preview;
+ContextMenu.Items = Items;
+
+export { ContextMenu };
diff --git a/packages/expo-ui/components/ContextMenu/index.tsx b/packages/expo-ui/components/ContextMenu/index.tsx
index 342ebe647f9b7f..88a7befeeebef0 100644
--- a/packages/expo-ui/components/ContextMenu/index.tsx
+++ b/packages/expo-ui/components/ContextMenu/index.tsx
@@ -1,11 +1,26 @@
 import { requireNativeView } from 'expo';
-import { ReactElement, ReactNode, useMemo } from 'react';
+import { Children, ReactElement, ReactNode, useMemo } from 'react';
 import { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
 
+import { MenuElement, transformChildrenToElementArray } from './utils';
 import { ButtonProps } from '../Button';
 import { PickerProps } from '../Picker';
 import { SwitchProps } from '../Switch';
-import { MenuElement, transformChildrenToElementArray } from './utils';
+
+const MenuNativeView: React.ComponentType<NativeMenuProps> = requireNativeView(
+  'ExpoUI',
+  'ContextMenu'
+);
+
+const MenuNativeTriggerView: React.ComponentType<object> = requireNativeView(
+  'ExpoUI',
+  'ContextMenuActivationElement'
+);
+
+const MenuNativePreviewView: React.ComponentType<object> = requireNativeView(
+  'ExpoUI',
+  'ContextMenuPreview'
+);
 
 type SubmenuElement =
   | ReactElement<ButtonProps>
@@ -47,13 +62,6 @@ export type ActivationMethod = 'singlePress' | 'longPress';
  * Props of the `ContextMenu` component.
  */
 export type ContextMenuProps = {
-  /**
-   * Items visible inside the context menu. The items should be wrapped in a `React.Fragment`.
-   * `Button`, `Switch` and `Submenu` components are supported on both Android and iOS.
-   * The `Picker` component is supported only on iOS. Remember to use components from the `@expo/ui` library.
-   */
-  Items: React.ReactElement<ContextMenuContentProps>;
-
   /**
    * Determines how the context menu will be activated.
    *
@@ -94,7 +102,10 @@ export type SubmenuProps = {
   children: React.ReactNode;
 };
 
-type NativeMenuProps = ContextMenuProps & {
+/**
+ * @hidden
+ */
+export type NativeMenuProps = ContextMenuProps & {
   elements: MenuElement[];
   onContextMenuButtonPressed: (
     event: NativeSyntheticEvent<{ contextMenuElementID: string }>
@@ -114,11 +125,6 @@ type NativeMenuProps = ContextMenuProps & {
   ) => void;
 };
 
-const MenuNativeView: React.ComponentType<NativeMenuProps> = requireNativeView(
-  'ExpoUI',
-  'ContextMenu'
-);
-
 /**
  * The `Submenu` component is used to create a nested context menu. Submenus can be infinitely nested.
  * Android does not support nesting in the context menu. All the submenus will be flat-mapped into a single level with multiple titled sections.
@@ -127,6 +133,30 @@ export function Submenu(props: SubmenuProps) {
   return <></>;
 }
 
+/**
+ * Items visible inside the context menu. Pass input components as immidiate children of the tag.
+ * `Button`, `Switch` and `Submenu` components are supported on both Android and iOS.
+ * The `Picker` component is supported only on iOS. Remember to use components from the `@expo/ui` library.
+ */
+export function Items(props: { children: React.ReactNode }) {
+  return <></>;
+}
+Items.tag = 'Items';
+/**
+ * The component visible all the time that triggers the menu when tapped or long-pressed.
+ */
+export function Trigger(props: { children: React.ReactNode }) {
+  return <MenuNativeTriggerView {...props} />;
+}
+
+/**
+ * The component visible above the menu when it is opened.
+ * @platform ios
+ */
+export function Preview(props: { children: React.ReactNode }) {
+  return <MenuNativePreviewView {...props} />;
+}
+
 /**
  * `ContextMenu` allows you to create a context menu, which can be used to provide additional options to the user.
  *
@@ -136,9 +166,13 @@ export function Submenu(props: SubmenuProps) {
  * - Android does not support nesting in the context menu. All the submenus will be flat-mapped into a single level with multiple sections. The `title` prop of the `Button`, which opens the submenu on iOS will be used as a section title.
  * - Android does not support showing a `Picker` element in the context menu.
  */
-export function ContextMenu(props: ContextMenuProps) {
+function ContextMenu(props: ContextMenuProps) {
   const eventHandlersMap: EventHandlers = {};
-  const initialChildren = props.Items.props.children;
+  const initialChildren = Children.map(
+    props.children as any,
+    (c: { type: { tag: string }; props: { children: React.ReactNode } }) =>
+      c.type.tag === Items.tag ? c.props.children : null
+  );
   const processedElements = useMemo(
     () => transformChildrenToElementArray(initialChildren, eventHandlersMap),
     [initialChildren]
@@ -161,3 +195,9 @@ export function ContextMenu(props: ContextMenuProps) {
     />
   );
 }
+
+ContextMenu.Trigger = Trigger;
+ContextMenu.Preview = Preview;
+ContextMenu.Items = Items;
+
+export { ContextMenu };
diff --git a/packages/expo-ui/ios/ContextMenu/ContextMenu.swift b/packages/expo-ui/ios/ContextMenu/ContextMenu.swift
index 091f5f5297f913..4115a9fac3da0b 100644
--- a/packages/expo-ui/ios/ContextMenu/ContextMenu.swift
+++ b/packages/expo-ui/ios/ContextMenu/ContextMenu.swift
@@ -87,14 +87,77 @@ struct LongPressContextMenu<ActivationElement: View>: View {
   }
 }
 
+struct LongPressContextMenuWithPreview<ActivationElement: View, Preview: View>: View {
+  let elements: [ContextMenuElement]?
+  let activationElement: ActivationElement
+  let preview: Preview
+  let props: ContextMenuProps?
+
+  var body: some View {
+    if #available(iOS 16.0, tvOS 16.0, *) {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      }, preview: {
+        preview
+      })
+    } else {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      })
+    }
+  }
+}
+
+struct ContextMenuPreview: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuPreviewProps
+
+  var body: some View {
+    Children()
+  }
+}
+
+struct ContextMenuActivationElement: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuActivationElementProps
+
+  var body: some View {
+    Children()
+  }
+}
+
 struct ContextMenu: ExpoSwiftUI.View {
   @EnvironmentObject var props: ContextMenuProps
 
   var body: some View {
     if props.activationMethod == .singlePress {
-      SinglePressContextMenu(elements: props.elements, activationElement: Children(), props: props)
+      let activationElement = props.children?.filter({
+        $0.view is ExpoSwiftUI.HostingView<ContextMenuActivationElementProps, ContextMenuActivationElement>
+      })
+      SinglePressContextMenu(
+        elements: props.elements,
+        activationElement: UnwrappedChildren(children: activationElement),
+        props: props
+      )
     } else {
-      LongPressContextMenu(elements: props.elements, activationElement: Children(), props: props)
+      let preview = props.children?.filter({
+        $0.view is ExpoSwiftUI.HostingView<ContextMenuPreviewProps, ContextMenuPreview>
+      })
+      let activationElement = props.children?.filter({
+        $0.view is ExpoSwiftUI.HostingView<ContextMenuActivationElementProps, ContextMenuActivationElement>
+      })
+      if preview?.count ?? 0 > 0 {
+        LongPressContextMenuWithPreview(
+          elements: props.elements,
+          activationElement: UnwrappedChildren(children: activationElement),
+          preview: UnwrappedChildren(children: preview),
+          props: props
+        )
+      } else {
+        LongPressContextMenu(
+          elements: props.elements,
+          activationElement: UnwrappedChildren(children: activationElement),
+          props: props
+        )
+      }
     }
   }
 }
diff --git a/packages/expo-ui/ios/ContextMenu/ContextMenuRecords.swift b/packages/expo-ui/ios/ContextMenu/ContextMenuRecords.swift
index 91ad4d49c7e134..09cab5ba231ae7 100644
--- a/packages/expo-ui/ios/ContextMenu/ContextMenuRecords.swift
+++ b/packages/expo-ui/ios/ContextMenu/ContextMenuRecords.swift
@@ -27,3 +27,9 @@ internal class ContextMenuProps: ExpoSwiftUI.ViewProps {
   var onContextMenuSwitchCheckedChanged = EventDispatcher()
   @Field var activationMethod: ActivationMethod? = .singlePress
 }
+
+internal class ContextMenuPreviewProps: ExpoSwiftUI.ViewProps {
+}
+
+internal class ContextMenuActivationElementProps: ExpoSwiftUI.ViewProps {
+}
diff --git a/packages/expo-ui/ios/ExpoUIModule.swift b/packages/expo-ui/ios/ExpoUIModule.swift
index 9fc11b36028835..e392cf08238db0 100644
--- a/packages/expo-ui/ios/ExpoUIModule.swift
+++ b/packages/expo-ui/ios/ExpoUIModule.swift
@@ -12,6 +12,8 @@ public class ExpoUIModule: Module {
     View(SectionView.self)
     View(SliderView.self)
     View(ExpoUI.ContextMenu.self)
+    View(ExpoUI.ContextMenuActivationElement.self)
+    View(ExpoUI.ContextMenuPreview.self)
     View(ColorPickerView.self)
     View(DateTimePickerView.self)
     View(TextInputView.self)


Reviews:
user: login: behenate
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-11T09:47:32Z
user: login: aleqsio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T12:32:00Z

Review comments:
diff_hunk: @@ -87,14 +87,77 @@ struct LongPressContextMenu<ActivationElement: View>: View {
   }
 }
 
+struct LongPressContextMenuWithPreview<ActivationElement: View, Preview: View>: View {
+  let elements: [ContextMenuElement]?
+  let activationElement: ActivationElement
+  let preview: Preview
+  let props: ContextMenuProps?
+
+  var body: some View {
+    if #available(iOS 16.0, tvOS 16.0, *) {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      }, preview: {
+        preview
+      })
+    } else {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      })
+    }
+  }
+}
+
+struct ContextMenuPreview: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuPreviewProps
+
+  var body: some View {
+    Children()
+  }
+}
+
+struct ContextMenuActivationElement: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuActivationElementProps
+
+  var body: some View {
+    Children()
+  }
+}
+
 struct ContextMenu: ExpoSwiftUI.View {
   @EnvironmentObject var props: ContextMenuProps
 
   var body: some View {
     if props.activationMethod == .singlePress {
-      SinglePressContextMenu(elements: props.elements, activationElement: Children(), props: props)
+      let activationElement = props.children?.filter({
+        $0.view is ExpoSwiftUI.HostingView<ContextMenuActivationElementProps, ContextMenuActivationElement>
path: packages/expo-ui/ios/ContextMenu/ContextMenu.swift
user: login: behenate
body: Will we encounter any unwanted behaviour if there is more than one Trigger element?
created_at: 2025-03-11T09:47:27Z
author_association: MEMBER
diff_hunk: @@ -87,14 +87,77 @@ struct LongPressContextMenu<ActivationElement: View>: View {
   }
 }
 
+struct LongPressContextMenuWithPreview<ActivationElement: View, Preview: View>: View {
+  let elements: [ContextMenuElement]?
+  let activationElement: ActivationElement
+  let preview: Preview
+  let props: ContextMenuProps?
+
+  var body: some View {
+    if #available(iOS 16.0, tvOS 16.0, *) {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      }, preview: {
+        preview
+      })
+    } else {
+      activationElement.contextMenu(menuItems: {
+        MenuItems(fromElements: elements, props: props)
+      })
+    }
+  }
+}
+
+struct ContextMenuPreview: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuPreviewProps
+
+  var body: some View {
+    Children()
+  }
+}
+
+struct ContextMenuActivationElement: ExpoSwiftUI.View {
+  @EnvironmentObject var props: ContextMenuActivationElementProps
+
+  var body: some View {
+    Children()
+  }
+}
+
 struct ContextMenu: ExpoSwiftUI.View {
   @EnvironmentObject var props: ContextMenuProps
 
   var body: some View {
     if props.activationMethod == .singlePress {
-      SinglePressContextMenu(elements: props.elements, activationElement: Children(), props: props)
+      let activationElement = props.children?.filter({
+        $0.view is ExpoSwiftUI.HostingView<ContextMenuActivationElementProps, ContextMenuActivationElement>
path: packages/expo-ui/ios/ContextMenu/ContextMenu.swift
user: login: aleqsio
body: Imo it's not a big problem – we can just document you're only supposed to have one trigger. Should work fine though
created_at: 2025-03-14T12:32:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-07T18:45:00Z
body: Subscribed to pull request
| File Patterns | Mentions |
| - | - |
| packages/expo-modules-core/\*\* | @kudo, @lukmccall |
| packages/expo-ui/\*\* | @behenate |

Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)
<!-- codemention header -->
author_association: CONTRIBUTOR
user: login: expo-bot
created_at: 2025-03-10T14:52:58Z
body: <!-- Made with ❤️ by `expotools code-review` -->

*Hi there! 👋 I'm a bot whose goal is to ensure your contributions meet our guidelines.*

I've found some issues in your pull request that should be addressed (click on them for more details) 👇

<details>
  <summary><strong>⚠️ Suggestion</strong>: Missing changelog entries</summary>

&NewLine;
Your changes should be noted in the changelog, e.g.: 
`- Add context menu previews and change API ([#35369](https://github.com/expo/expo/pull/35369) by [@aleqsio](https://github.com/aleqsio))`
Read [Updating Changelogs](https://github.com/expo/expo/blob/main/guides/contributing/Updating%20Changelogs.md) guide and consider adding an appropriate entry to the following changelogs: 
- [`packages/expo-modules-core/CHANGELOG.md`](https://github.com/expo/expo/blob/@aleqsio/change-context-menu-api-add-previews/packages/expo-modules-core/CHANGELOG.md)
</details>


---
*Generated by ExpoBot 🤖 against 715e98467b7643154778432a094be96ff4bf54fe*
author_association: COLLABORATOR