Dialog Primitive
A modal dialog that interrupts the user with important content and expects a response.
Installation
Section titled “Installation”Install the component via your command line.
npx expo install @rn-primitives/dialogInstall @radix-ui/react-dialog
npx expo install @radix-ui/react-dialogCopy/paste the following code for web to ~/components/primitives/dialog/dialog.web.tsx
import * as Dialog from '@radix-ui/react-dialog';import { useComposedRefs, useControllableState, useIsomorphicLayoutEffect,} from '~/components/primitives/hooks';import { 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);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, }); const Component = asChild ? Slot : 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;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, role: _role, disabled, ref, ...props}: TriggerComponentProps) => { const triggerRef = React.useRef<TriggerRef>(null); const composedRef = useComposedRefs(ref, triggerRef); const { onOpenChange, open } = useRootContext(); function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (triggerRef.current) { const augRef = triggerRef.current as unknown as HTMLButtonElement; augRef.dataset.state = open ? 'open' : 'closed'; augRef.type = 'button'; } }, [open]);
const Component = asChild ? Slot : Pressable; return ( <Dialog.Trigger disabled={disabled ?? undefined} asChild> <Component ref={composedRef} 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} />;}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, ref, ...props }: OverlayComponentProps) => { const Component = asChild ? Slot : Pressable; return ( <Dialog.Overlay forceMount={forceMount}> <Component ref={ref} {...props} /> </Dialog.Overlay> );};
Overlay.displayName = 'OverlayWebDialog';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild, forceMount, onOpenAutoFocus, onCloseAutoFocus, onEscapeKeyDown, onInteractOutside, onPointerDownOutside, ref, ...props}: ContentComponentProps) => { const Component = asChild ? Slot : 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';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled, ref, ...props }: CloseComponentProps) => { const closeRef = React.useRef<CloseRef>(null); const composedRef = useComposedRefs(ref, closeRef); const { onOpenChange, open } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (closeRef.current) { const augRef = closeRef.current as unknown as HTMLButtonElement; augRef.type = 'button'; } }, []);
const Component = asChild ? Slot : Pressable; return ( <> <Dialog.Close disabled={disabled ?? undefined} asChild> <Component ref={composedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Dialog.Close> </> );};
Close.displayName = 'CloseWebDialog';type TitleComponentProps = TitleProps & React.RefAttributes<TitleRef>;
const Title = ({ asChild, ref, ...props }: TitleComponentProps) => { const Component = asChild ? Slot : Text; return ( <Dialog.Title asChild> <Component ref={ref} {...props} /> </Dialog.Title> );};
Title.displayName = 'TitleWebDialog';type DescriptionComponentProps = DescriptionProps & React.RefAttributes<DescriptionRef>;
const Description = ({ asChild, ref, ...props }: DescriptionComponentProps) => { const Component = asChild ? Slot : 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 { 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);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { const nativeID = React.useId(); const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, });
const Component = asChild ? Slot : 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;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot : 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> );}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, closeOnPress = true, onPress: OnPressProp, ref, ...props}: OverlayComponentProps) => { 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; return <Component ref={ref} onPress={onPress} {...props} />;};
Overlay.displayName = 'OverlayNativeDialog';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild, forceMount, ref, ...props }: ContentComponentProps) => { 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; 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';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: CloseComponentProps) => { const { onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> );};
Close.displayName = 'CloseNativeDialog';type TitleComponentProps = TitleProps & React.RefAttributes<TitleRef>;
const Title = ({ ref, ...props }: TitleComponentProps) => { const { nativeID } = useRootContext(); return <Text ref={ref} role='heading' nativeID={`${nativeID}_label`} {...props} />;};
Title.displayName = 'TitleNativeDialog';type DescriptionComponentProps = DescriptionProps & React.RefAttributes<DescriptionRef>;
const Description = ({ ref, ...props }: DescriptionComponentProps) => { 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 { 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);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, open: openProp, defaultOpen, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { const nativeID = React.useId(); const [open = false, onOpenChange] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChangeProp, });
const Component = asChild ? Slot : 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;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { open, onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot : 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> );}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, closeOnPress = true, onPress: OnPressProp, ref, ...props}: OverlayComponentProps) => { 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; return <Component ref={ref} onPress={onPress} {...props} />;};
Overlay.displayName = 'OverlayNativeDialog';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild, forceMount, ref, ...props }: ContentComponentProps) => { 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; 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';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: CloseComponentProps) => { const { onOpenChange } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> );};
Close.displayName = 'CloseNativeDialog';type TitleComponentProps = TitleProps & React.RefAttributes<TitleRef>;
const Title = ({ ref, ...props }: TitleComponentProps) => { const { nativeID } = useRootContext(); return <Text ref={ref} role='heading' nativeID={`${nativeID}_label`} {...props} />;};
Title.displayName = 'TitleNativeDialog';type DescriptionComponentProps = DescriptionProps & React.RefAttributes<DescriptionRef>;
const Description = ({ ref, ...props }: DescriptionComponentProps) => { 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,};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> );}Extends View props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
| open | boolean | (optional) |
| onOpenChange | (value: boolean) => void | (optional) |
| defaultOpen | boolean | (optional) |
Trigger
Section titled “Trigger”Extends Pressable props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
Overlay
Section titled “Overlay”Extends Pressable props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
| forceMount | true | undefined; | (optional) |
| closeOnPress | boolean | (optional) |
Portal
Section titled “Portal”| Prop | Type | Note |
|---|---|---|
| children* | React.ReactNode | |
| forceMount | true | undefined | (optional) |
| hostName | string | Web Only (optional) |
| container | HTMLElement | null | undefined | Web Only (optional) |
Content
Section titled “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) |
Extends Text props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
Description
Section titled “Description”Extends Text props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
Extends Pressable props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
useRootContext
Section titled “useRootContext”Must be used within a Root component. It provides the following values from the dialog: open, and onOpenChange.