Dialog Primitive
A modal dialog that interrupts the user with important content and expects a response.
Installation
Install the component via your command line.
npx expo install @rn-primitives/dialog
Install @radix-ui/react-dialog
npx expo install @radix-ui/react-dialog
Copy/paste the following code for web to ~/components/primitives/dialog/dialog.web.tsx
import * as Dialog from '@radix-ui/react-dialog';import { useAugmentedRef, useControllableState, useIsomorphicLayoutEffect,} from '~/components/primitives/hooks';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { Pressable, Text, View, type GestureResponderEvent } from 'react-native';import type { CloseProps, CloseRef, ContentProps, ContentRef, DescriptionProps, DescriptionRef, OverlayProps, OverlayRef, PortalProps, RootContext, RootProps, RootRef, TitleProps, TitleRef, TriggerProps, TriggerRef,} from './types';
const DialogContext = React.createContext<RootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, }); const Component = asChild ? Slot.View : View; return ( <DialogContext.Provider value={{ open, onOpenChange }}> <Dialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}> <Component ref={ref} {...viewProps} /> </Dialog.Root> </DialogContext.Provider> ); });
Root.displayName = 'RootWebDialog';
function useRootContext() { const context = React.useContext(DialogContext); if (!context) { throw new Error('Dialog compound components cannot be rendered outside the Dialog component'); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, role: _role, disabled, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref }); const { onOpenChange, open } = useRootContext(); function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLButtonElement; augRef.dataset.state = open ? 'open' : 'closed'; augRef.type = 'button'; } }, [open]);
const Component = asChild ? Slot.Pressable : Pressable; return ( <Dialog.Trigger disabled={disabled ?? undefined} asChild> <Component ref={augmentedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Dialog.Trigger> ); });
Trigger.displayName = 'TriggerWebDialog';
function Portal({ forceMount, container, children }: PortalProps) { return <Dialog.Portal forceMount={forceMount} children={children} container={container} />;}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, ...props }, ref) => { const Component = asChild ? Slot.Pressable : Pressable; return ( <Dialog.Overlay forceMount={forceMount}> <Component ref={ref} {...props} /> </Dialog.Overlay> ); });
Overlay.displayName = 'OverlayWebDialog';
const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild, forceMount, onOpenAutoFocus, onCloseAutoFocus, onEscapeKeyDown, onInteractOutside, onPointerDownOutside, ...props }, ref ) => { const Component = asChild ? Slot.View : View; return ( <Dialog.Content onOpenAutoFocus={onOpenAutoFocus} onCloseAutoFocus={onCloseAutoFocus} onEscapeKeyDown={onEscapeKeyDown} onInteractOutside={onInteractOutside} onPointerDownOutside={onPointerDownOutside} forceMount={forceMount} > <Component ref={ref} {...props} /> </Dialog.Content> ); });
Content.displayName = 'ContentWebDialog';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref }); const { onOpenChange, open } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLButtonElement; augRef.type = 'button'; } }, []);
const Component = asChild ? Slot.Pressable : Pressable; return ( <> <Dialog.Close disabled={disabled ?? undefined} asChild> <Component ref={augmentedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Dialog.Close> </> ); });
Close.displayName = 'CloseWebDialog';
const Title = React.forwardRef<TitleRef, TitleProps>(({ asChild, ...props }, ref) => { const Component = asChild ? Slot.Text : Text; return ( <Dialog.Title asChild> <Component ref={ref} {...props} /> </Dialog.Title> );});
Title.displayName = 'TitleWebDialog';
const Description = React.forwardRef<DescriptionRef, DescriptionProps>( ({ asChild, ...props }, ref) => { const Component = asChild ? Slot.Text : Text; return ( <Dialog.Description asChild> <Component ref={ref} {...props} /> </Dialog.Description> ); });
Description.displayName = 'DescriptionWebDialog';
export { Close, Content, Description, Overlay, Portal, Root, Title, Trigger, useRootContext };
Copy/paste the following code for native to ~/components/primitives/dialog/dialog.tsx
import { useControllableState } from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { BackHandler, GestureResponderEvent, Pressable, Text, View } from 'react-native';import type { CloseProps, CloseRef, ContentProps, ContentRef, DescriptionProps, DescriptionRef, OverlayProps, OverlayRef, PortalProps, RootContext, RootProps, RootRef, TitleProps, TitleRef, TriggerProps, TriggerRef,} from './types';
const DialogContext = React.createContext<(RootContext & { nativeID: string }) | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { const nativeID = React.useId(); const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, });
const Component = asChild ? Slot.View : View; return ( <DialogContext.Provider value={{ open, onOpenChange, nativeID, }} > <Component ref={ref} {...viewProps} /> </DialogContext.Provider> ); });
Root.displayName = 'RootNativeDialog';
function useRootContext() { const context = React.useContext(DialogContext); if (!context) { throw new Error('Dialog compound components cannot be rendered outside the Dialog component'); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Trigger.displayName = 'TriggerNativeDialog';
/** * @warning when using a custom `<PortalHost />`, you might have to adjust the Content's sideOffset to account for nav elements like headers. */function Portal({ forceMount, hostName, children }: PortalProps) { const value = useRootContext();
if (!forceMount) { if (!value.open) { return null; } }
return ( <RNPPortal hostName={hostName} name={`${value.nativeID}_portal`}> <DialogContext.Provider value={value}>{children}</DialogContext.Provider> </RNPPortal> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, closeOnPress = true, onPress: OnPressProp, ...props }, ref) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (closeOnPress) { onOpenChange(!open); } OnPressProp?.(ev); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativeDialog';
const Content = React.forwardRef<ContentRef, ContentProps>( ({ asChild, forceMount, ...props }, ref) => { const { open, nativeID, onOpenChange } = useRootContext();
React.useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { onOpenChange(false); return true; });
return () => { backHandler.remove(); }; }, []);
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='dialog' nativeID={nativeID} aria-labelledby={`${nativeID}_label`} aria-describedby={`${nativeID}_desc`} aria-modal={true} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativeDialog';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Close.displayName = 'CloseNativeDialog';
const Title = React.forwardRef<TitleRef, TitleProps>((props, ref) => { const { nativeID } = useRootContext(); return <Text ref={ref} role='heading' nativeID={`${nativeID}_label`} {...props} />;});
Title.displayName = 'TitleNativeDialog';
const Description = React.forwardRef<DescriptionRef, DescriptionProps>((props, ref) => { const { nativeID } = useRootContext(); return <Text ref={ref} nativeID={`${nativeID}_desc`} {...props} />;});
Description.displayName = 'DescriptionNativeDialog';
export { Close, Content, Description, Overlay, Portal, Root, Title, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}
Copy/paste the following code for types to ~/components/primitives/dialog/types.ts
import type { ForceMountable, PressableRef, SlottablePressableProps, SlottableTextProps, SlottableViewProps, TextRef, ViewRef,} from '~/components/primitives/types';
type RootContext = { open: boolean; onOpenChange: (value: boolean) => void;};
type RootProps = SlottableViewProps & { open?: boolean; defaultOpen?: boolean; onOpenChange?: (value: boolean) => void;};
interface PortalProps extends ForceMountable { children: React.ReactNode; /** * Platform: NATIVE ONLY */ hostName?: string; /** * Platform: WEB ONLY */ container?: HTMLElement | null | undefined;}type OverlayProps = ForceMountable & SlottablePressableProps & { /** * Platform: NATIVE ONLY - default: true */ closeOnPress?: boolean; };type ContentProps = ForceMountable & SlottableViewProps & { /** * Platform: WEB ONLY */ onOpenAutoFocus?: (ev: Event) => void; /** * Platform: WEB ONLY */ onCloseAutoFocus?: (ev: Event) => void; /** * Platform: WEB ONLY */ onEscapeKeyDown?: (ev: Event) => void; /** * Platform: WEB ONLY */ onInteractOutside?: (ev: Event) => void; /** * Platform: WEB ONLY */ onPointerDownOutside?: (ev: Event) => void; };
type TriggerProps = SlottablePressableProps;type CloseProps = SlottablePressableProps;type TitleProps = SlottableTextProps;type DescriptionProps = SlottableTextProps;
type CloseRef = PressableRef;type ContentRef = ViewRef;type DescriptionRef = TextRef;type OverlayRef = PressableRef;type RootRef = ViewRef;type TitleRef = TextRef;type TriggerRef = PressableRef;
export type { CloseProps, CloseRef, ContentProps, ContentRef, DescriptionProps, DescriptionRef, OverlayProps, OverlayRef, PortalProps, RootContext, RootProps, RootRef, TitleProps, TitleRef, TriggerProps, TriggerRef,};
Copy/paste the following code for exporting to ~/components/primitives/dialog/index.ts
export * from './dialog';export * from './types';
Copy/paste the following code for native to ~/components/primitives/dialog/index.tsx
import { useControllableState } from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { BackHandler, GestureResponderEvent, Pressable, Text, View } from 'react-native';import type { CloseProps, CloseRef, ContentProps, ContentRef, DescriptionProps, DescriptionRef, OverlayProps, OverlayRef, PortalProps, RootContext, RootProps, RootRef, TitleProps, TitleRef, TriggerProps, TriggerRef,} from './types';
const DialogContext = React.createContext<(RootContext & { nativeID: string }) | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ...viewProps }, ref) => { const nativeID = React.useId(); const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, });
const Component = asChild ? Slot.View : View; return ( <DialogContext.Provider value={{ open, onOpenChange, nativeID, }} > <Component ref={ref} {...viewProps} /> </DialogContext.Provider> ); });
Root.displayName = 'RootNativeDialog';
function useRootContext() { const context = React.useContext(DialogContext); if (!context) { throw new Error('Dialog compound components cannot be rendered outside the Dialog component'); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Trigger.displayName = 'TriggerNativeDialog';
/** * @warning when using a custom `<PortalHost />`, you might have to adjust the Content's sideOffset to account for nav elements like headers. */function Portal({ forceMount, hostName, children }: PortalProps) { const value = useRootContext();
if (!forceMount) { if (!value.open) { return null; } }
return ( <RNPPortal hostName={hostName} name={`${value.nativeID}_portal`}> <DialogContext.Provider value={value}>{children}</DialogContext.Provider> </RNPPortal> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, closeOnPress = true, onPress: OnPressProp, ...props }, ref) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (closeOnPress) { onOpenChange(!open); } OnPressProp?.(ev); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativeDialog';
const Content = React.forwardRef<ContentRef, ContentProps>( ({ asChild, forceMount, ...props }, ref) => { const { open, nativeID, onOpenChange } = useRootContext();
React.useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { onOpenChange(false); return true; });
return () => { backHandler.remove(); }; }, []);
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='dialog' nativeID={nativeID} aria-labelledby={`${nativeID}_label`} aria-describedby={`${nativeID}_desc`} aria-modal={true} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativeDialog';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Close.displayName = 'CloseNativeDialog';
const Title = React.forwardRef<TitleRef, TitleProps>((props, ref) => { const { nativeID } = useRootContext(); return <Text ref={ref} role='heading' nativeID={`${nativeID}_label`} {...props} />;});
Title.displayName = 'TitleNativeDialog';
const Description = React.forwardRef<DescriptionRef, DescriptionProps>((props, ref) => { const { nativeID } = useRootContext(); return <Text ref={ref} nativeID={`${nativeID}_desc`} {...props} />;});
Description.displayName = 'DescriptionNativeDialog';
export { Close, Content, Description, Overlay, Portal, Root, Title, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}
Copy/paste the following code for types to ~/components/primitives/dialog/types.ts
import type { ForceMountable, PressableRef, SlottablePressableProps, SlottableTextProps, SlottableViewProps, TextRef, ViewRef,} from '~/components/primitives/types';
type RootContext = { open: boolean; onOpenChange: (value: boolean) => void;};
type RootProps = SlottableViewProps & { open?: boolean; defaultOpen?: boolean; onOpenChange?: (value: boolean) => void;};
interface PortalProps extends ForceMountable { children: React.ReactNode; /** * Platform: NATIVE ONLY */ hostName?: string; /** * Platform: WEB ONLY */ container?: HTMLElement | null | undefined;}type OverlayProps = ForceMountable & SlottablePressableProps & { /** * Platform: NATIVE ONLY - default: true */ closeOnPress?: boolean; };type ContentProps = ForceMountable & SlottableViewProps & { /** * Platform: WEB ONLY */ onOpenAutoFocus?: (ev: Event) => void; /** * Platform: WEB ONLY */ onCloseAutoFocus?: (ev: Event) => void; /** * Platform: WEB ONLY */ onEscapeKeyDown?: (ev: Event) => void; /** * Platform: WEB ONLY */ onInteractOutside?: (ev: Event) => void; /** * Platform: WEB ONLY */ onPointerDownOutside?: (ev: Event) => void; };
type TriggerProps = SlottablePressableProps;type CloseProps = SlottablePressableProps;type TitleProps = SlottableTextProps;type DescriptionProps = SlottableTextProps;
type CloseRef = PressableRef;type ContentRef = ViewRef;type DescriptionRef = TextRef;type OverlayRef = PressableRef;type RootRef = ViewRef;type TitleRef = TextRef;type TriggerRef = PressableRef;
export type { CloseProps, CloseRef, ContentProps, ContentRef, DescriptionProps, DescriptionRef, OverlayProps, OverlayRef, PortalProps, RootContext, RootProps, RootRef, TitleProps, TitleRef, TriggerProps, TriggerRef,};
Usage
import * as DialogPrimitive from '@rn-primitives/dialog';import { Text } from 'react-native';
function Example() { return ( <DialogPrimitive.Root> <DialogPrimitive.Trigger> <Text>Show Dialog</Text> </DialogPrimitive.Trigger>
<DialogPrimitive.Portal> <DialogPrimitive.Overlay> <DialogPrimitive.Content> <DialogPrimitive.Title>Dialog Title</DialogPrimitive.Title> <DialogPrimitive.Description> Dialog description. </DialogPrimitive.Description> <DialogPrimitive.Close><Text>Close</Text></DialogPrimitive.Close> </DialogPrimitive.Content> </DialogPrimitive.Overlay> </DialogPrimitive.Portal> </DialogPrimitive.Root> );}
Props
Root
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
open | boolean | (optional) |
onOpenChange | (value: boolean) => void | (optional) |
defaultOpen | boolean | (optional) |
Trigger
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
Overlay
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined; | (optional) |
closeOnPress | boolean | (optional) |
Portal
Prop | Type | Note |
---|---|---|
children* | React.ReactNode | |
forceMount | true | undefined | (optional) |
hostName | string | Web Only (optional) |
container | HTMLElement | null | undefined | Web Only (optional) |
Content
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined | (optional) |
alignOffset | number | Native Only (optional) |
insets | Insets | Native Only (optional) |
avoidCollisions | boolean | Native Only (optional) |
align | ’start’ | ‘center’ | ‘end’ | Native Only (optional) |
side | ’top’ | ‘bottom’ | Native Only (optional) |
sideOffset | number | Native Only (optional) |
disablePositioningStyle | boolean | Native Only (optional) |
onOpenAutoFocus | (ev: Event) => void | Web Only (optional) |
onCloseAutoFocus | (ev: Event) => void | Web Only (optional) |
onEscapeKeyDown | (ev: Event) => void | Web Only (optional) |
onInteractOutside | (ev: Event) => void | Web Only (optional) |
onPointerDownOutside | (ev: Event) => void | Web Only (optional) |
Title
Extends Text
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
Description
Extends Text
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
Close
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
useRootContext
Must be used within a Root
component. It provides the following values from the dialog: open
, and onOpenChange
.