PoC using Radix UI primitives on iOS, Android and Web with Expo and Next.js 🎉
This is a proof of concept using Radix UI primitives on iOS, Android and Web with Expo and Next.js
Made this as a proposal to create universal UI primitives and components based on Radix UI that works with Expo by using React Native for Web.
The app is based on code samples from Record Pool, a side project kickstarted during Expo Jam (a week of dogfooding at Expo).
Bottom sheet menu on mobile:
Dropdown menu on desktop:
Code shared between iOS, Android and Web
cd packages/app
The interesting stuff is located at:
packages/app/components/record
packages/app/design-system/menu
packages/app/design-system/bottom-sheet
App entrypoints and navigation / routing
Expo entrypoint: App.tsx
cd packages/expo-next
yarn start:expo
to start iOS and Android app with Expo
Next.js entrypoint: src/pages/_app.tsx
cd packages/expo-next
yarn dev
to start web app
Custom Radix UI components using React Native for Web and React Native Bottom Sheet
cd packages/radix
Trigger on desktop:
packages/radix/context-menu/src/ContextMenu.tsx
diff --git a/packages/react/context-menu/src/ContextMenu.tsx b/packages/react/context-menu/src/ContextMenu.tsx
index f5c83ab..674b0c8 100644
--- a/packages/react/context-menu/src/ContextMenu.tsx
+++ b/packages/react/context-menu/src/ContextMenu.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Platform, View } from 'react-native';
import { composeEventHandlers } from '@radix-ui/primitive';
import { createContext } from '@radix-ui/react-context';
import { Primitive, extendPrimitive } from '@radix-ui/react-primitive';
@@ -53,7 +54,7 @@ ContextMenu.displayName = CONTEXT_MENU_NAME;
* -----------------------------------------------------------------------------------------------*/
const TRIGGER_NAME = 'ContextMenuTrigger';
-const TRIGGER_DEFAULT_TAG = 'span';
+const TRIGGER_DEFAULT_TAG = Platform.OS === 'web' ? 'span' : View;
type ContextMenuTriggerOwnProps = Polymorphic.OwnProps<typeof Primitive>;
type ContextMenuTriggerPrimitive = Polymorphic.ForwardRefComponent<
Trigger on desktop:
Trigger on mobile:
packages/radix/dropdown-menu/src/DropdownMenu.tsx
diff --git a/packages/react/dropdown-menu/src/DropdownMenu.tsx b/packages/react/dropdown-menu/src/DropdownMenu.tsx
index 47a2c55..ef20902 100644
--- a/packages/react/dropdown-menu/src/DropdownMenu.tsx
+++ b/packages/react/dropdown-menu/src/DropdownMenu.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Platform, View, Modal } from 'react-native';
import { composeEventHandlers } from '@radix-ui/primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { createContext } from '@radix-ui/react-context';
@@ -6,6 +7,7 @@ import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { extendPrimitive } from '@radix-ui/react-primitive';
import * as MenuPrimitive from '@radix-ui/react-menu';
import { useId } from '@radix-ui/react-id';
+import BottomSheet from '@gorhom/bottom-sheet';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
@@ -64,7 +66,7 @@ DropdownMenu.displayName = DROPDOWN_MENU_NAME;
* -----------------------------------------------------------------------------------------------*/
const TRIGGER_NAME = 'DropdownMenuTrigger';
-const TRIGGER_DEFAULT_TAG = 'button';
+const TRIGGER_DEFAULT_TAG = Platform.OS === 'web' ? 'button' : View;
type DropdownMenuTriggerOwnProps = Omit<
Polymorphic.OwnProps<typeof MenuPrimitive.Anchor>,
@@ -134,35 +136,70 @@ const DropdownMenuContent = React.forwardRef((props, forwardedRef) => {
...contentProps
} = props;
const context = useDropdownMenuContext(CONTENT_NAME);
+ const bottomSheetRef = React.useRef<BottomSheet>(null);
+ const handleSheetChanges = React.useCallback(
+ (index: number) => {
+ if (index === -1) {
+ context.onOpenChange(false);
+ }
+ },
+ [context]
+ );
+
+ if (Platform.OS === 'web') {
+ return (
+ <MenuPrimitive.Content
+ id={context.contentId}
+ {...contentProps}
+ ref={forwardedRef}
+ disableOutsidePointerEvents={disableOutsidePointerEvents}
+ disableOutsideScroll={disableOutsideScroll}
+ portalled={portalled}
+ style={{
+ ...props.style,
+ // re-namespace exposed content custom property
+ ['--radix-dropdown-menu-content-transform-origin' as any]: 'var(--radix-popper-transform-origin)',
+ }}
+ trapFocus
+ onCloseAutoFocus={composeEventHandlers(onCloseAutoFocus, (event) => {
+ event.preventDefault();
+ context.triggerRef.current?.focus();
+ })}
+ onPointerDownOutside={composeEventHandlers(
+ props.onPointerDownOutside,
+ (event) => {
+ const targetIsTrigger = context.triggerRef.current?.contains(
+ event.target as HTMLElement
+ );
+ // prevent dismissing when clicking the trigger
+ // as it's already setup to close, otherwise it would close and immediately open.
+ if (targetIsTrigger) event.preventDefault();
+ },
+ { checkForDefaultPrevented: false }
+ )}
+ />
+ );
+ }
+
return (
- <MenuPrimitive.Content
- id={context.contentId}
- {...contentProps}
- ref={forwardedRef}
- disableOutsidePointerEvents={disableOutsidePointerEvents}
- disableOutsideScroll={disableOutsideScroll}
- portalled={portalled}
- style={{
- ...props.style,
- // re-namespace exposed content custom property
- ['--radix-dropdown-menu-content-transform-origin' as any]: 'var(--radix-popper-transform-origin)',
- }}
- trapFocus
- onCloseAutoFocus={composeEventHandlers(onCloseAutoFocus, (event) => {
- event.preventDefault();
- context.triggerRef.current?.focus();
- })}
- onPointerDownOutside={composeEventHandlers(
- props.onPointerDownOutside,
- (event) => {
- const targetIsTrigger = context.triggerRef.current?.contains(event.target as HTMLElement);
- // prevent dismissing when clicking the trigger
- // as it's already setup to close, otherwise it would close and immediately open.
- if (targetIsTrigger) event.preventDefault();
- },
- { checkForDefaultPrevented: false }
- )}
- />
+ <Modal
+ animationType="none"
+ transparent={true}
+ visible={context.open}
+ onRequestClose={() => {
+ context.onOpenChange(!context.open);
+ }}
+ >
+ <BottomSheet
+ {...contentProps}
+ ref={bottomSheetRef}
+ animateOnMount={true}
+ enablePanDownToClose={true}
+ onChange={handleSheetChanges}
+ />
+ </Modal>
);
}) as DropdownMenuContentPrimitive;
packages/radix/menu/src/Menu.tsx
diff --git a/packages/react/menu/src/Menu.tsx b/packages/react/menu/src/Menu.tsx
index 005ba42..b3f60d0 100644
--- a/packages/react/menu/src/Menu.tsx
+++ b/packages/react/menu/src/Menu.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Platform, View } from 'react-native';
import { RemoveScroll } from 'react-remove-scroll';
import { hideOthers } from 'aria-hidden';
import { composeEventHandlers } from '@radix-ui/primitive';
@@ -396,6 +397,18 @@ const MenuItem = React.forwardRef((props, forwardedRef) => {
MenuItem.displayName = ITEM_NAME;
+/* -------------------------------------------------------------------------------------------------
+ * MenuItemNative
+ * -----------------------------------------------------------------------------------------------*/
+
+const MenuItemNative = React.forwardRef((props, forwardedRef) => {
+ const { ...itemProps } = props;
+
+ return <View {...itemProps} ref={forwardedRef} />;
+}) as MenuItemPrimitive;
+
+MenuItemNative.displayName = ITEM_NAME;
+
/* -------------------------------------------------------------------------------------------------
* MenuCheckboxItem
* -----------------------------------------------------------------------------------------------*/
@@ -594,7 +607,7 @@ const Anchor = MenuAnchor;
const Content = MenuContent;
const Group = MenuGroup;
const Label = MenuLabel;
-const Item = MenuItem;
+const Item = Platform.OS === 'web' ? MenuItem : MenuItemNative;
const CheckboxItem = MenuCheckboxItem;
const RadioGroup = MenuRadioGroup;
const RadioItem = MenuRadioItem;